Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5accf73363 | |||
| 299a157561 | |||
| af1af5c211 | |||
| 713e8bf415 | |||
| 8d42bfffc9 | |||
| 095efb1cca | |||
| 632dadd064 | |||
| dace2c4e66 | |||
| c01e4acf0a |
+1
-187
File diff suppressed because one or more lines are too long
@@ -110,6 +110,9 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
|
|||||||
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
|
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
|
||||||
| TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
| TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
||||||
| TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
| TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
||||||
|
| TC-API-1.29 | CORS — untrusted origin blocked (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://evil.example.com` header | Response has **no** `Access-Control-Allow-Origin` header — attacker origin is not reflected | `Access-Control-Allow-Origin: https://evil.example.com` present in response |
|
||||||
|
| TC-API-1.30 | CORS — trusted origin allowed (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://uat.groombook.dev` header | `Access-Control-Allow-Origin: https://uat.groombook.dev` + `Access-Control-Allow-Credentials: true` | CORS header absent or trusted origin rejected |
|
||||||
|
| TC-API-1.31 | CORS — untrusted preflight blocked (GRO-2586) | `curl -i -X OPTIONS https://uat.groombook.dev/api/auth/sign-in/social -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'` | Response has **no** `Access-Control-Allow-Origin: https://evil.example.com` | Preflight reflects attacker origin |
|
||||||
|
|
||||||
### 4.2 Client Management
|
### 4.2 Client Management
|
||||||
|
|
||||||
@@ -436,6 +439,22 @@ Both use the stops' stored `latitude`/`longitude` in `stopOrder`: **origin = fir
|
|||||||
| TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) |
|
| TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) |
|
||||||
| TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) |
|
| TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) |
|
||||||
|
|
||||||
|
|
||||||
|
### 4.19 Boot Resilience — ECONNRESET Recovery (GRO-2652)
|
||||||
|
|
||||||
|
Verifies the API process does not crash on transient boot-time DB connection resets and that auth routes degrade gracefully until initialization succeeds.
|
||||||
|
|
||||||
|
| TC | Test Case | Steps | Expected Result |
|
||||||
|
|----|-----------|-------|-----------------|
|
||||||
|
| TC-API-19.1 | Health endpoint available before auth init | 1. Deploy the image (or restart the api pod)<br>2. `GET /health` immediately (within first 2 s of pod start) | 200 `{"status":"ok"}` — server accepts requests before `initAuth()` completes |
|
||||||
|
| TC-API-19.2 | Auth routes return 503 when auth not yet initialized | 1. Temporarily set `OIDC_ISSUER` to an unreachable host so `initAuth()` keeps retrying<br>2. `POST /api/auth/sign-in/email` during the retry window | 503 `{"error":"Authentication not configured"}` — process stays alive, does not exit |
|
||||||
|
| TC-API-19.3 | Pod does not crash on first-attempt DB reset | 1. Review pod restart count after normal deployment<br>2. Confirm `kubectl get pod -n groombook` shows `RESTARTS: 0` (or same as before deploy) for the new pod | No new restarts — ECONNRESET causes retry, not process exit |
|
||||||
|
| TC-API-19.4 | DB query retry log lines visible | After deploy, `kubectl logs -n groombook <api-pod>` | If any DB retry occurred, log lines matching `[auth] DB query attempt N failed` are present; on clean boot no retry lines appear |
|
||||||
|
| TC-API-19.5 | Auth init retry log lines visible | When auth init fails and retries, check pod logs | Log lines matching `[auth] initAuth attempt N failed` present; process continues; no `process.exit` |
|
||||||
|
| TC-API-19.6 | Auth succeeds after transient DB hiccup | 1. Allow pod to retry until DB is available<br>2. `POST /api/auth/sign-in/email` with valid credentials after init succeeds | 200 with session cookie — auth recovers without pod restart |
|
||||||
|
| TC-API-19.7 | Normal sign-in still works end-to-end | Follow TC-WEB-SSO-3 (SSO sign-in) on UAT | Successful sign-in, staff list visible — no regression from resilience changes |
|
||||||
|
| TC-API-19.8 | Public routes unaffected during auth retry | While auth is retrying (TC-API-19.2 setup), `GET /api/branding` | 200 with branding data — public routes bypass auth and serve normally |
|
||||||
|
|
||||||
## Pass/Fail Criteria
|
## Pass/Fail Criteria
|
||||||
|
|
||||||
**Pass:**
|
**Pass:**
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { enforceAuthCors } from "../lib/auth-cors.js";
|
||||||
|
|
||||||
|
const TRUSTED = ["https://uat.groombook.dev", "https://dev.groombook.dev"];
|
||||||
|
|
||||||
|
/** Simulates Better Auth reflecting the request Origin (the pre-fix bug). */
|
||||||
|
function makeReflectedResponse(origin: string | null): Response {
|
||||||
|
return new Response('{"ok":true}', {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(origin
|
||||||
|
? {
|
||||||
|
"Access-Control-Allow-Origin": origin,
|
||||||
|
"Access-Control-Allow-Credentials": "true",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("enforceAuthCors (GRO-2586)", () => {
|
||||||
|
it("passes trusted origin through with credentials", () => {
|
||||||
|
const origin = "https://uat.groombook.dev";
|
||||||
|
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin);
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips ACAO for attacker origin (credentialed cross-origin read blocked)", () => {
|
||||||
|
const origin = "https://evil.example.com";
|
||||||
|
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips ACAO when no Origin header (undefined)", () => {
|
||||||
|
const res = enforceAuthCors(undefined, TRUSTED, makeReflectedResponse(null));
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves non-CORS response headers and status from Better Auth", () => {
|
||||||
|
const origin = "https://evil.example.com";
|
||||||
|
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
||||||
|
expect(res.headers.get("Content-Type")).toBe("application/json");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("second trusted origin is also allowed", () => {
|
||||||
|
const origin = "https://dev.groombook.dev";
|
||||||
|
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty string origin is treated as untrusted", () => {
|
||||||
|
const res = enforceAuthCors("", TRUSTED, makeReflectedResponse(""));
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+26
-4
@@ -3,6 +3,7 @@ import { Hono } from "hono";
|
|||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
||||||
|
import { enforceAuthCors } from "./lib/auth-cors.js";
|
||||||
import { clientsRouter } from "./routes/clients.js";
|
import { clientsRouter } from "./routes/clients.js";
|
||||||
import { petsRouter } from "./routes/pets.js";
|
import { petsRouter } from "./routes/pets.js";
|
||||||
import { servicesRouter } from "./routes/services.js";
|
import { servicesRouter } from "./routes/services.js";
|
||||||
@@ -200,9 +201,10 @@ api.use("*", resolveStaffMiddleware);
|
|||||||
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||||
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||||
const authRouter = new Hono();
|
const authRouter = new Hono();
|
||||||
authRouter.all("/*", (c) => {
|
authRouter.all("/*", async (c) => {
|
||||||
try {
|
try {
|
||||||
return getAuth().handler(c.req.raw);
|
const res = await getAuth().handler(c.req.raw);
|
||||||
|
return enforceAuthCors(c.req.header("origin"), TRUSTED_ORIGINS, res);
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ error: "Authentication not configured" }, 503);
|
return c.json({ error: "Authentication not configured" }, 503);
|
||||||
}
|
}
|
||||||
@@ -290,14 +292,34 @@ api.route("/search", searchRouter);
|
|||||||
api.route("/buffer-rules", bufferRulesRouter);
|
api.route("/buffer-rules", bufferRulesRouter);
|
||||||
api.route("/routes", routesRouter);
|
api.route("/routes", routesRouter);
|
||||||
|
|
||||||
|
// Start the HTTP server first so /health and public routes are available immediately.
|
||||||
|
// Auth initialization runs afterward with retry — a transient DB ECONNRESET at boot
|
||||||
|
// must not crash the process (GRO-2652). Auth routes return 503 until initAuth succeeds.
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
await initAuth();
|
|
||||||
console.log(`API server listening on port ${port}`);
|
|
||||||
const server = serve({ fetch: app.fetch, port });
|
const server = serve({ fetch: app.fetch, port });
|
||||||
|
console.log(`API server listening on port ${port}`);
|
||||||
|
|
||||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||||
startReminderScheduler();
|
startReminderScheduler();
|
||||||
|
|
||||||
|
let initAttempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
await initAuth();
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
initAttempt++;
|
||||||
|
const delay = Math.min(2 ** initAttempt * 500, 30_000);
|
||||||
|
console.error(`[auth] initAuth attempt ${initAttempt} failed: ${err}`);
|
||||||
|
if (initAttempt >= 10) {
|
||||||
|
console.error("[auth] auth init permanently failed — auth endpoints will serve 503");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
console.error(`[auth] retrying in ${delay}ms`);
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
console.log("Shutting down gracefully...");
|
console.log("Shutting down gracefully...");
|
||||||
// SIGTERM/SIGINT → server.close() → callback → process.exit(0)
|
// SIGTERM/SIGINT → server.close() → callback → process.exit(0)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Enforces the trusted-origins CORS allowlist on a raw Response from Better Auth.
|
||||||
|
* Better Auth reflects the request Origin into Access-Control-Allow-Origin
|
||||||
|
* regardless of the trustedOrigins config, allowing credentialed cross-origin reads
|
||||||
|
* from arbitrary attacker origins. This wrapper strips CORS headers for any origin
|
||||||
|
* not in the allowlist. (GRO-2586)
|
||||||
|
*/
|
||||||
|
export function enforceAuthCors(
|
||||||
|
requestOrigin: string | undefined,
|
||||||
|
trustedOrigins: string[],
|
||||||
|
res: Response
|
||||||
|
): Response {
|
||||||
|
const headers = new Headers(res.headers);
|
||||||
|
if (requestOrigin && trustedOrigins.includes(requestOrigin)) {
|
||||||
|
headers.set("Access-Control-Allow-Origin", requestOrigin);
|
||||||
|
headers.set("Access-Control-Allow-Credentials", "true");
|
||||||
|
} else {
|
||||||
|
headers.delete("Access-Control-Allow-Origin");
|
||||||
|
headers.delete("Access-Control-Allow-Credentials");
|
||||||
|
}
|
||||||
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
||||||
|
}
|
||||||
+21
-6
@@ -124,13 +124,28 @@ export async function initAuth(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Try to load config from DB
|
// Step 1: Try to load config from DB, with retry-with-backoff for transient ECONNRESET (GRO-2652).
|
||||||
|
// A single connection reset during boot must not abort initialization.
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [dbConfig] = await db
|
let dbQueryRows: (typeof authProviderConfig.$inferSelect)[] = [];
|
||||||
.select()
|
let dbAttempt = 0;
|
||||||
.from(authProviderConfig)
|
while (true) {
|
||||||
.where(eq(authProviderConfig.enabled, true))
|
try {
|
||||||
.limit(1);
|
dbQueryRows = await db
|
||||||
|
.select()
|
||||||
|
.from(authProviderConfig)
|
||||||
|
.where(eq(authProviderConfig.enabled, true))
|
||||||
|
.limit(1);
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
dbAttempt++;
|
||||||
|
if (dbAttempt >= 5) throw err;
|
||||||
|
const delay = Math.min(1000 * 2 ** (dbAttempt - 1), 8_000);
|
||||||
|
console.warn(`[auth] DB query attempt ${dbAttempt} failed (${err}), retrying in ${delay}ms`);
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [dbConfig] = dbQueryRows;
|
||||||
|
|
||||||
let providerConfig: {
|
let providerConfig: {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user