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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||
let mockGetSession: ReturnType<typeof vi.fn>;
|
||||
let insertedSession: Record<string, unknown> | null = null;
|
||||
let mockClientRow: Record<string, unknown> | null = null;
|
||||
let mockStaffRow: Record<string, unknown> | 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<string, unknown>) => ({
|
||||
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<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -172,7 +172,7 @@ export async function initAuth(): Promise<void> {
|
||||
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)");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user