Compare commits
No commits in common. "b0ac42441f56fecb6716d47725898c569cdd24c0" and "f8ec37369119dd2e8f4ca4f6298fe873add685b4" have entirely different histories.
b0ac42441f
...
f8ec373691
6 changed files with 25 additions and 217 deletions
|
|
@ -13,34 +13,16 @@ import { toSafeProfile } from "../lib/safe-profile.js";
|
||||||
* typed, no cast needed.
|
* typed, no cast needed.
|
||||||
*/
|
*/
|
||||||
export interface AuthLocals {
|
export interface AuthLocals {
|
||||||
/** The authenticated profile, resolved from the session cookie's JWT (or an `Authorization: Bearer` one, for cookie-less clients). */
|
/** The authenticated profile, resolved from the session cookie's JWT. */
|
||||||
userProfile: SafeUserProfile;
|
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
|
* Express middleware guarding routes that require an authenticated
|
||||||
* profile. Reads the session cookie (falling back to an `Authorization:
|
* profile. Reads the session cookie, verifies the JWT, and re-checks
|
||||||
* Bearer <jwt>` header when there's no cookie — see {@link readBearerToken}),
|
* `tokenVersion` against the database — so a stateless JWT can still be
|
||||||
* verifies the JWT, and re-checks `tokenVersion` against the database — so a
|
* invalidated server-side (e.g. on password change / logout-everywhere,
|
||||||
* stateless JWT can still be invalidated server-side (e.g. on password
|
* once that feature exists) despite carrying no server-side session.
|
||||||
* change / logout-everywhere, once that feature exists) despite carrying no
|
|
||||||
* server-side session.
|
|
||||||
*
|
*
|
||||||
* On success, attaches the resolved profile to `res.locals.userProfile`
|
* On success, attaches the resolved profile to `res.locals.userProfile`
|
||||||
* (typed via {@link AuthLocals}) for downstream handlers to use.
|
* (typed via {@link AuthLocals}) for downstream handlers to use.
|
||||||
|
|
@ -51,9 +33,9 @@ function readBearerToken(header: string | undefined): string | null {
|
||||||
* project-wide ambient augmentation silently changing every `Request` in
|
* project-wide ambient augmentation silently changing every `Request` in
|
||||||
* the codebase, whether or not it went through this middleware.
|
* the codebase, whether or not it went through this middleware.
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — no cookie
|
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
||||||
* and no usable Bearer header, malformed/expired JWT, unknown profile, or
|
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
|
||||||
* stale tokenVersion. Never distinguishes the reason to the client.
|
* Never distinguishes the reason to the client.
|
||||||
*/
|
*/
|
||||||
export async function requireAuth(
|
export async function requireAuth(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|
@ -61,11 +43,7 @@ export async function requireAuth(
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const cookieToken = req.cookies?.[env.AUTH_COOKIE_NAME];
|
const token = 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") {
|
if (typeof token !== "string") {
|
||||||
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import {
|
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||||
type AuthResponse,
|
|
||||||
deleteAccountSchema,
|
|
||||||
loginSchema,
|
|
||||||
signupSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import type { CookieOptions, Response } from "express";
|
import type { CookieOptions, Response } from "express";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
|
|
@ -48,11 +43,7 @@ authRouter.post(
|
||||||
const input = signupSchema.parse(req.body);
|
const input = signupSchema.parse(req.body);
|
||||||
const { profile, token } = await signup(input);
|
const { profile, token } = await signup(input);
|
||||||
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
||||||
// Token is echoed in the body too, not just the cookie — see
|
res.status(201).json(profile);
|
||||||
// `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);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -63,7 +54,7 @@ authRouter.post(
|
||||||
const input = loginSchema.parse(req.body);
|
const input = loginSchema.parse(req.body);
|
||||||
const { profile, token } = await login(input);
|
const { profile, token } = await login(input);
|
||||||
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
||||||
res.status(200).json({ ...profile, token } satisfies AuthResponse);
|
res.status(200).json(profile);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,17 +55,6 @@ describe("Auth", () => {
|
||||||
expect(res.headers["set-cookie"]?.[0]).to.include("session=");
|
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 () => {
|
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
|
||||||
const payload = buildSignupPayload();
|
const payload = buildSignupPayload();
|
||||||
await request(app).post("/auth/signup").send(payload);
|
await request(app).post("/auth/signup").send(payload);
|
||||||
|
|
@ -106,16 +95,6 @@ describe("Auth", () => {
|
||||||
expect(res.body.email).to.equal(payload.email);
|
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 () => {
|
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/auth/login")
|
.post("/auth/login")
|
||||||
|
|
@ -155,41 +134,6 @@ 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", () => {
|
describe("DELETE /auth/me", () => {
|
||||||
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||||
const res = await request(app).delete("/auth/me").send({ password: "whatever" });
|
const res = await request(app).delete("/auth/me").send({ password: "whatever" });
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import {
|
||||||
type AllergyView,
|
type AllergyView,
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
type AssignLeftoverInput,
|
type AssignLeftoverInput,
|
||||||
type AuthResponse,
|
|
||||||
type BrowsableSourceItemView,
|
type BrowsableSourceItemView,
|
||||||
type CreateRecipeInput,
|
type CreateRecipeInput,
|
||||||
type DietView,
|
type DietView,
|
||||||
|
|
@ -32,7 +31,6 @@ import {
|
||||||
type UpdateRecipeInput,
|
type UpdateRecipeInput,
|
||||||
type UtensilView,
|
type UtensilView,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { noopTokenStore, type TokenStore } from "./token-store";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`).
|
* Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`).
|
||||||
|
|
@ -45,25 +43,6 @@ import { noopTokenStore, type TokenStore } from "./token-store";
|
||||||
*/
|
*/
|
||||||
const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "";
|
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
|
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
|
||||||
* status. Carries the same {@link ErrorCode} the API returned, so callers
|
* status. Carries the same {@link ErrorCode} the API returned, so callers
|
||||||
|
|
@ -105,20 +84,12 @@ export class ApiClient {
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
): Promise<TResponseBody> {
|
): Promise<TResponseBody> {
|
||||||
try {
|
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}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
// Required for the httpOnly session cookie to be sent/received — the
|
// Required for the httpOnly session cookie to be sent/received — the
|
||||||
// API and the web app run on different origins.
|
// API and the web app run on different origins.
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", ...options.headers },
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -146,35 +117,19 @@ export class ApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Creates a profile and starts a session — no household yet, that's an optional step of the onboarding wizard. */
|
||||||
* Creates a profile and starts a session — no household yet, that's an
|
public signup(input: SignupInput): Promise<SafeUserProfile> {
|
||||||
* optional step of the onboarding wizard. Persists the returned token via
|
return this._request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
|
||||||
* 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 — see {@link signup} re: the token. */
|
/** Verifies credentials and starts a session. */
|
||||||
public async login(input: LoginInput): Promise<SafeUserProfile> {
|
public login(input: LoginInput): Promise<SafeUserProfile> {
|
||||||
const { token, ...profile } = await this._request<AuthResponse>("/auth/login", {
|
return this._request("/auth/login", { method: "POST", body: JSON.stringify(input) });
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
await activeTokenStore.set(token);
|
|
||||||
return profile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ends the current session, and drops any stored token. */
|
/** Ends the current session. */
|
||||||
public async logout(): Promise<void> {
|
public logout(): Promise<void> {
|
||||||
await this._request("/auth/logout", { method: "POST" });
|
return this._request("/auth/logout", { method: "POST" });
|
||||||
await activeTokenStore.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
|
/** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
|
||||||
|
|
@ -182,10 +137,9 @@ export class ApiClient {
|
||||||
return this._request("/auth/me");
|
return this._request("/auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. Drops any stored token on success. */
|
/** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. */
|
||||||
public async deleteAccount(password: string): Promise<void> {
|
public deleteAccount(password: string): Promise<void> {
|
||||||
await this._request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
|
return this._request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
|
||||||
await activeTokenStore.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,22 +20,3 @@ export interface SafeUserProfile {
|
||||||
/** FK to this profile's diet preference, or `null` if unset. */
|
/** FK to this profile's diet preference, or `null` if unset. */
|
||||||
dietId: number | null;
|
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