feat(auth): accepte un jeton Bearer en repli du cookie de session
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>
This commit is contained in:
parent
f8ec373691
commit
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.
|
* typed, no cast needed.
|
||||||
*/
|
*/
|
||||||
export interface AuthLocals {
|
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;
|
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, verifies the JWT, and re-checks
|
* profile. Reads the session cookie (falling back to an `Authorization:
|
||||||
* `tokenVersion` against the database — so a stateless JWT can still be
|
* Bearer <jwt>` header when there's no cookie — see {@link readBearerToken}),
|
||||||
* invalidated server-side (e.g. on password change / logout-everywhere,
|
* verifies the JWT, and re-checks `tokenVersion` against the database — so a
|
||||||
* once that feature exists) despite carrying no server-side session.
|
* 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`
|
* 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.
|
||||||
|
|
@ -33,9 +51,9 @@ export interface AuthLocals {
|
||||||
* 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 — missing
|
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — no cookie
|
||||||
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
|
* and no usable Bearer header, malformed/expired JWT, unknown profile, or
|
||||||
* Never distinguishes the reason to the client.
|
* stale tokenVersion. Never distinguishes the reason to the client.
|
||||||
*/
|
*/
|
||||||
export async function requireAuth(
|
export async function requireAuth(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|
@ -43,7 +61,11 @@ export async function requireAuth(
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) {
|
) {
|
||||||
try {
|
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") {
|
if (typeof token !== "string") {
|
||||||
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
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 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";
|
||||||
|
|
@ -43,7 +48,11 @@ 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);
|
||||||
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 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);
|
res.status(200).json({ ...profile, token } satisfies AuthResponse);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,17 @@ 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);
|
||||||
|
|
@ -95,6 +106,16 @@ 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")
|
||||||
|
|
@ -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", () => {
|
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,6 +3,7 @@ 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,
|
||||||
|
|
@ -31,6 +32,7 @@ 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`).
|
||||||
|
|
@ -43,6 +45,25 @@ import {
|
||||||
*/
|
*/
|
||||||
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
|
||||||
|
|
@ -84,12 +105,20 @@ 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: { "Content-Type": "application/json", ...options.headers },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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> {
|
* Creates a profile and starts a session — no household yet, that's an
|
||||||
return this._request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
|
* 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. */
|
/** Verifies credentials and starts a session — see {@link signup} re: the token. */
|
||||||
public login(input: LoginInput): Promise<SafeUserProfile> {
|
public async login(input: LoginInput): Promise<SafeUserProfile> {
|
||||||
return this._request("/auth/login", { method: "POST", body: JSON.stringify(input) });
|
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. */
|
/** Ends the current session, and drops any stored token. */
|
||||||
public logout(): Promise<void> {
|
public async logout(): Promise<void> {
|
||||||
return this._request("/auth/logout", { method: "POST" });
|
await 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. */
|
||||||
|
|
@ -137,9 +182,10 @@ 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. */
|
/** 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 deleteAccount(password: string): Promise<void> {
|
public async deleteAccount(password: string): Promise<void> {
|
||||||
return this._request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
|
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. */
|
/** 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