Compare commits

..

3 Commits

Author SHA1 Message Date
Flea Flicker 7f7e908e45 feat(GRO-2425): split CORS_ORIGIN on commas for multiple trusted auth origins
CI / Test (pull_request) Successful in 32s
CI / Lint & Typecheck (pull_request) Successful in 38s
CI / Build & Push Docker Images (pull_request) Failing after 1m2s
Changes trustedOrigins from a single-string wrap to a comma-split array
so both demo.groombook.dev and groombook.farh.net can coexist as trusted
Better-Auth origins via a single CORS_ORIGIN env value.

Updated UAT_PLAYBOOK.md §4.1 — added TC-API-1.27 and TC-API-1.28 for
multi-origin callbackURL coverage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-18 00:42:45 +00:00
Flea Flicker 10b78d810d Merge pull request 'feat(GRO-2359): add POST /api/portal/clients-from-auth for OOBE' (#212) from feature/2357-p2-portal-clients-from-auth into dev
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Images (push) Successful in 41s
GRO-2359 (api): feat(GRO-2359): add POST /api/portal/clients-from-auth for OOBE (#212)
2026-06-11 16:34:34 +00:00
Flea Flicker cdeebec021 feat(GRO-2359): add POST /api/portal/clients-from-auth for OOBE (web)
CI / Test (pull_request) Successful in 29s
CI / Lint & Typecheck (pull_request) Successful in 41s
CI / Build & Push Docker Images (pull_request) Successful in 1m40s
The OOBE flow on the web portal calls this endpoint to create a fresh
`clients` row bound to the Better Auth user's email when the SSO
bridge returns 404. Returns 201 on success, 409 if a client with that
email already exists (portal-selection case), 401/503 on auth issues,
400 on invalid body.

The OOBE success path navigates the user back to `/` and lets the
existing `session-from-auth` re-bridge; the new client is now
resolvable by email, so the bridge mints a real portal session.

Tests cover: 401 (no session), 400 (zod), 201 + persisted values
(name trimmed, optional fields normalized to null), 409 (existing
client or unique-constraint race), 503 (auth not configured).

Paired with the web PR on `feature/2357-p2-sso-to-oobe-routing`.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-11 16:17:16 +00:00
4 changed files with 2 additions and 89 deletions
-3
View File
@@ -110,9 +110,6 @@ 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.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.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
-60
View File
@@ -1,60 +0,0 @@
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();
});
});
+2 -4
View File
@@ -3,7 +3,6 @@ import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
import { enforceAuthCors } from "./lib/auth-cors.js";
import { clientsRouter } from "./routes/clients.js";
import { petsRouter } from "./routes/pets.js";
import { servicesRouter } from "./routes/services.js";
@@ -201,10 +200,9 @@ api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono();
authRouter.all("/*", async (c) => {
authRouter.all("/*", (c) => {
try {
const res = await getAuth().handler(c.req.raw);
return enforceAuthCors(c.req.header("origin"), TRUSTED_ORIGINS, res);
return getAuth().handler(c.req.raw);
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
-22
View File
@@ -1,22 +0,0 @@
/**
* 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 });
}