Compare commits
3 commits
f8ec373691
...
b0ac42441f
| Author | SHA1 | Date | |
|---|---|---|---|
| b0ac42441f | |||
| 5d7e4d6f6a | |||
| 1d8c001035 |
6 changed files with 217 additions and 25 deletions
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
40
apps/web/src/api/token-store.ts
Normal file
40
apps/web/src/api/token-store.ts
Normal 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(),
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue