From 7e329ff72f76d91b67c7e46a12b67510233b9657 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 28 May 2026 15:00:15 +0000 Subject: [PATCH 1/3] fix(gro-1866): add session-from-auth portal endpoint and role scope Adds POST /api/portal/session-from-auth which bridges a valid Better Auth customer session (from SSO login) to a portal impersonation session, so real SSO customers can access the client portal. The endpoint is registered before the validatePortalSession catch-all so it is not subject to that middleware. It validates the Better Auth session from request cookies, looks up the client by email, creates an active impersonation session, and returns { sessionId, clientId, clientName }. Also adds "role" to the genericOAuth scopes so Authentik propagates the role claim into Better Auth user objects (GRO-1862 root cause fix). Co-Authored-By: Paperclip --- src/__tests__/portalSessionFromAuth.test.ts | 176 ++++++++++++++++++++ src/lib/auth.ts | 2 +- src/routes/portal.ts | 72 ++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/portalSessionFromAuth.test.ts diff --git a/src/__tests__/portalSessionFromAuth.test.ts b/src/__tests__/portalSessionFromAuth.test.ts new file mode 100644 index 0000000..5079f0d --- /dev/null +++ b/src/__tests__/portalSessionFromAuth.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; +const CLIENT_EMAIL = "alice@example.com"; +const CLIENT_NAME = "Alice Smith"; + +const BETTER_AUTH_SESSION = { + user: { + id: "auth-user-001", + email: CLIENT_EMAIL, + name: CLIENT_NAME, + }, + session: { + id: "ba-session-001", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, +}; + +const MOCK_CLIENT = { + id: CLIENT_ID, + email: CLIENT_EMAIL, + name: CLIENT_NAME, +}; + +let mockGetAuth: ReturnType; +let mockGetSession: ReturnType; +let insertedSession: Record | null = null; +let mockClientRow: Record | null = null; +let mockStaffRow: Record | null = null; + +function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + return new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => makeChainable(target); + } + // @ts-expect-error proxy + return target[prop]; + }, + }); +} + +vi.mock("@groombook/db", () => { + const impersonationSessions = new Proxy( + { _name: "impersonationSessions" }, + { get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) } + ); + + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + const staff = new Proxy( + { _name: "staff" }, + { get: (t, p) => (p === "_name" ? "staff" : { table: "staff", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "clients") { + return makeChainable(mockClientRow ? [mockClientRow] : []); + } + if (table._name === "staff") { + return makeChainable(mockStaffRow ? [mockStaffRow] : []); + } + return makeChainable([]); + }, + }), + insert: () => ({ + into: (table: { _name: string }) => ({ + values: (vals: Record) => ({ + returning: () => { + if (table._name === "impersonationSessions") { + insertedSession = { id: "new-session-001", ...vals }; + return [insertedSession]; + } + return []; + }, + }), + }), + }), + }), + impersonationSessions, + clients, + staff, + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +vi.mock("../lib/auth.js", () => ({ + getAuth: vi.fn(), +})); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +describe("POST /portal/session-from-auth", () => { + beforeEach(() => { + insertedSession = null; + mockClientRow = null; + mockStaffRow = null; + mockGetSession = vi.fn(); + mockGetAuth = vi.fn(() => ({ + api: { + getSession: mockGetSession, + }, + })); + vi.mocked(getAuth).mockImplementation(mockGetAuth); + }); + + it("returns 401 when no Better Auth session", async () => { + mockGetSession.mockResolvedValue(null); + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 404 when authenticated user has no client record", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + mockClientRow = null; + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("No client record found for this user"); + }); + + it("returns a portal session with sessionId, clientId, clientName when client is found", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + mockClientRow = MOCK_CLIENT; + mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" }; + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toHaveProperty("sessionId"); + expect(body).toHaveProperty("clientId", CLIENT_ID); + expect(body).toHaveProperty("clientName", CLIENT_NAME); + }); + + it("creates a portal session with reason sso-bridge", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + mockClientRow = MOCK_CLIENT; + mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" }; + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(201); + expect(insertedSession).not.toBeNull(); + expect((insertedSession as Record).reason).toBe("sso-bridge"); + }); + + it("returns 503 when auth is not configured", async () => { + mockGetAuth.mockImplementation(() => { + throw new Error("Auth not initialized"); + }); + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(503); + }); +}); \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index da2b2d1..ff1e125 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -172,7 +172,7 @@ export async function initAuth(): Promise { clientSecret: oidcClientSecret, issuerUrl: oidcIssuer, internalBaseUrl: process.env.OIDC_INTERNAL_BASE, - scopes: "openid profile email", + scopes: "openid profile email role", }; console.log("[auth] Using env var config (no DB config found)"); } diff --git a/src/routes/portal.ts b/src/routes/portal.ts index a4c2b87..05b09ed 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -71,6 +71,78 @@ portalRouter.post( } ); +// Bridge Better Auth session → portal session for real SSO customers (GRO-1866). +// Registered BEFORE the /* middleware so it is NOT subject to validatePortalSession. +import { getAuth } from "../lib/auth.js"; + +portalRouter.post("/session-from-auth", async (c) => { + let auth; + try { + auth = getAuth(); + } catch { + return c.json({ error: "Authentication not configured" }, 503); + } + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + const db = getDb(); + const [client] = await db + .select() + .from(clients) + .where(eq(clients.email, session.user.email)) + .limit(1); + + if (!client) { + return c.json({ error: "No client record found for this user" }, 404); + } + + const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001"; + + let staffId = DEMO_STAFF_ID; + const [demoStaff] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.id, DEMO_STAFF_ID)) + .limit(1); + + if (!demoStaff) { + const [firstStaff] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.active, true)) + .limit(1); + if (!firstStaff) { + return c.json({ error: "No staff records found" }, 500); + } + staffId = firstStaff.id; + } + + const [portalSession] = await db + .insert(impersonationSessions) + .values({ + staffId, + clientId: client.id, + reason: "sso-bridge", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }) + .returning(); + + return c.json( + { + sessionId: portalSession.id, + clientId: client.id, + clientName: client.name, + }, + 201 + ); +}); + // Apply middleware to all portal routes portalRouter.use("/*", validatePortalSession, portalAudit); From fa67b75b761ddc57e87c0ca76c1db78ccb13e07a Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 28 May 2026 15:01:24 +0000 Subject: [PATCH 2/3] docs: add UAT test cases TC-API-8.8 through TC-API-8.11 for SSO bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds manual test cases covering: - TC-API-8.8: valid Better Auth session → portal session (201) - TC-API-8.9: no session → 401 - TC-API-8.10: no matching client → 404 - TC-API-8.11: returned sessionId works on subsequent portal calls Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index d03aeea..84bb88d 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -159,6 +159,10 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-8.5 | Add waitlist entry | POST /api/portal/waitlist with pet and service | 201 Created, waitlist entry created | | TC-API-8.6 | View portal invoices | GET /api/portal/invoices | 200 OK, list of client's invoices returned | | TC-API-8.7 | Pay multiple invoices | POST /api/portal/invoices/pay-multiple with invoice IDs | 200 OK, payment intent created | +| TC-API-8.8 | SSO bridge — valid Better Auth session | POST /api/portal/session-from-auth with valid Better Auth session cookie (authenticated SSO user with matching client email) | 201 Created, `{sessionId, clientId, clientName}` returned | +| TC-API-8.9 | SSO bridge — no Better Auth session | POST /api/portal/session-from-auth without Better Auth session cookie | 401 Unauthorized | +| TC-API-8.10 | SSO bridge — no matching client | POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record | 404 Not Found, error "No client record found for this user" | +| TC-API-8.11 | SSO bridge — returned session works on portal routes | After TC-API-8.8, use returned sessionId as `X-Impersonation-Session-Id` header on GET /api/portal/me | 200 OK, client profile returned | ### 4.9 Waitlist From b96b6c06fc1b9341eaf580d44c7226f33d1b3d99 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 28 May 2026 15:59:41 +0000 Subject: [PATCH 3/3] fix: add missing getAuth import and fix db.insert() mock chain Fixes two bugs found in QA review: - ReferenceError: getAuth not defined in beforeEach - add import - TypeError: wrong mock chain insert().into().values() vs insert().values() Co-Authored-By: Paperclip --- src/__tests__/portalSessionFromAuth.test.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/__tests__/portalSessionFromAuth.test.ts b/src/__tests__/portalSessionFromAuth.test.ts index 5079f0d..8448803 100644 --- a/src/__tests__/portalSessionFromAuth.test.ts +++ b/src/__tests__/portalSessionFromAuth.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; +import { getAuth } from "../lib/auth.js"; const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; const CLIENT_EMAIL = "alice@example.com"; @@ -71,17 +72,15 @@ vi.mock("@groombook/db", () => { return makeChainable([]); }, }), - insert: () => ({ - into: (table: { _name: string }) => ({ - values: (vals: Record) => ({ - returning: () => { - if (table._name === "impersonationSessions") { - insertedSession = { id: "new-session-001", ...vals }; - return [insertedSession]; - } - return []; - }, - }), + insert: (table: { _name: string }) => ({ + values: (vals: Record) => ({ + returning: () => { + if (table._name === "impersonationSessions") { + insertedSession = { id: "new-session-001", ...vals }; + return [insertedSession]; + } + return []; + }, }), }), }),