Merge pull request 'feat(auth): jeton Bearer en repli du cookie de session (portage mobile #39, phase 1)' (#54) from feat/mobile-bearer-auth into main
All checks were successful
CI / lint (push) Successful in 2m29s
CI / build (push) Successful in 2m42s
CI / intent-service-test (push) Successful in 4m1s
CI / e2e (push) Successful in 8m25s
CI / test (push) Successful in 15m30s

This commit is contained in:
kyuno 2026-09-06 03:23:56 +02:00
commit b0ac42441f
6 changed files with 217 additions and 25 deletions

View file

@ -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 <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, 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 <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.
@ -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");
}

View file

@ -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);
}),
);

View file

@ -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" });

View file

@ -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<TResponseBody> {
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<SafeUserProfile> {
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<SafeUserProfile> {
const { token, ...profile } = await this._request<AuthResponse>("/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<SafeUserProfile> {
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<SafeUserProfile> {
const { token, ...profile } = await this._request<AuthResponse>("/auth/login", {
method: "POST",
body: JSON.stringify(input),
});
await activeTokenStore.set(token);
return profile;
}
/** Ends the current session. */
public logout(): Promise<void> {
return this._request("/auth/logout", { method: "POST" });
/** Ends the current session, and drops any stored token. */
public async logout(): Promise<void> {
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<void> {
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<void> {
await this._request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
await activeTokenStore.clear();
}
/**

View file

@ -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 <token>` 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<string | null>;
/** Persists `token` as the current session token. */
set(token: string): Promise<void>;
/** Drops any stored token — logout, or account deletion. */
clear(): Promise<void>;
}
/**
* 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(),
};

View file

@ -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 <token>`
* 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;
}