diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index 55a3bfb..9005d7c 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -14,6 +14,7 @@ export interface CookiePayload { roles?: string[]; email?: string; phone?: string | null; + organizationId?: string | null; } export interface CookieInstruction { @@ -107,6 +108,7 @@ const COOKIE_REQUIREMENTS: Record< }, "/logout": { name: "accessCookieName", required: true }, "/users/me": { name: "accessCookieName", required: true }, + "/organizations": { name: "accessCookieName", required: true }, "/step-up/status": { name: "accessCookieName", required: true }, "/step-up/webauthn/start": { name: "accessCookieName", required: true }, "/step-up/webauthn/finish": { name: "accessCookieName", required: true }, @@ -136,6 +138,10 @@ const COOKIE_REQUIREMENTS: Record< name: "accessCookieName", required: true, }, + "/admin/organizations": { + name: "accessCookieName", + required: true, + }, "/system-config/admin": { name: "accessCookieName", @@ -229,6 +235,7 @@ export async function ensureCookies( roles: refreshed.roles, email: refreshed.email, phone: refreshed.phone, + organizationId: refreshed.organizationId ?? null, }, ttl: refreshed.ttl, domain: opts.cookieDomain, diff --git a/packages/core/src/handlers/finishLogin.ts b/packages/core/src/handlers/finishLogin.ts index 72df3f0..168f510 100644 --- a/packages/core/src/handlers/finishLogin.ts +++ b/packages/core/src/handlers/finishLogin.ts @@ -77,6 +77,7 @@ export async function finishLoginHandler( roles: data.roles, email: data.email, phone: data.phone, + organizationId: data.organizationId ?? null, }, ttl: data.ttl, domain: opts.cookieDomain, diff --git a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts index 5a6fb66..2497b2f 100644 --- a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts +++ b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts @@ -92,6 +92,7 @@ export async function pollMagicLinkConfirmationHandler( roles: data.roles, email: data.email, phone: data.phone, + organizationId: data.organizationId ?? null, }, ttl: data.ttl, domain: opts.cookieDomain, diff --git a/packages/core/src/handlers/switchOrganizationHandler.ts b/packages/core/src/handlers/switchOrganizationHandler.ts new file mode 100644 index 0000000..27e7fdd --- /dev/null +++ b/packages/core/src/handlers/switchOrganizationHandler.ts @@ -0,0 +1,95 @@ +import { authFetch } from "../authFetch.js"; +import type { CookiePayload } from "../ensureCookies.js"; +import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; + +export interface SwitchOrganizationInput { + organizationId: string; + authorization?: string; + forwardedClientIp?: string; +} + +export interface SwitchOrganizationOptions { + authServerUrl: string; + cookieDomain?: string; + accessCookieName: string; +} + +export interface SwitchOrganizationResult { + status: number; + body?: unknown; + error?: unknown; + setCookies?: { + name: string; + value: CookiePayload; + ttl: number; + domain?: string; + }[]; +} + +export async function switchOrganizationHandler( + input: SwitchOrganizationInput, + opts: SwitchOrganizationOptions, +): Promise { + const up = await authFetch( + `${opts.authServerUrl}/organizations/${encodeURIComponent(input.organizationId)}/switch`, + { + method: "POST", + authorization: input.authorization, + forwardedClientIp: input.forwardedClientIp, + }, + ); + + const data = await up.json(); + + if (!up.ok) { + return { + status: up.status, + error: data, + }; + } + + if (!data?.token || !data?.sub) { + return { + status: up.status, + body: data, + }; + } + + const verifiedAccessToken = await verifySignedAuthResponse( + data.token, + opts.authServerUrl, + ); + + if (!verifiedAccessToken) { + throw new Error("Invalid signed response from Auth Server"); + } + + if (verifiedAccessToken.sub !== data.sub) { + throw new Error("Signature mismatch with data payload"); + } + + const sessionId = + typeof verifiedAccessToken.sid === "string" + ? verifiedAccessToken.sid + : undefined; + + return { + status: up.status, + body: data, + setCookies: [ + { + name: opts.accessCookieName, + value: { + sub: data.sub, + ...(sessionId === undefined ? {} : { sessionId }), + roles: data.roles, + email: data.email, + phone: data.phone, + organizationId: data.organizationId ?? null, + }, + ttl: data.ttl, + domain: opts.cookieDomain, + }, + ], + }; +} diff --git a/packages/core/src/handlers/verifyLoginOtpHandler.ts b/packages/core/src/handlers/verifyLoginOtpHandler.ts index 925e8ca..ef69426 100644 --- a/packages/core/src/handlers/verifyLoginOtpHandler.ts +++ b/packages/core/src/handlers/verifyLoginOtpHandler.ts @@ -83,6 +83,7 @@ export async function verifyLoginOtpHandler( roles: data.roles, email: data.email, phone: data.phone, + organizationId: data.organizationId ?? null, }, ttl: data.ttl, domain: opts.cookieDomain, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 05a5f33..a064f67 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,3 +17,4 @@ export * from "./handlers/verifyLoginOtpHandler.js"; export * from "./handlers/verifyMagicLinkHandler.js"; export * from "./handlers/requestMagicLinkHandler.js"; export * from "./handlers/pollMagicLinkConfirmationHandler.js"; +export * from "./handlers/switchOrganizationHandler.js"; diff --git a/packages/core/src/refreshAccessToken.ts b/packages/core/src/refreshAccessToken.ts index 974d2ab..0cb842e 100644 --- a/packages/core/src/refreshAccessToken.ts +++ b/packages/core/src/refreshAccessToken.ts @@ -20,6 +20,7 @@ type RefreshAccessTokenResult = { roles?: string[]; email?: string; phone?: string | null; + organizationId?: string | null; ttl: number; refreshTtl: number; }; diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index c867b76..b079fe9 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -91,6 +91,7 @@ describe("ensureCookies", () => { roles: ["user"], email: "test@example.com", phone: "+14155552671", + organizationId: "org-123", ttl: 300, refreshTtl: 3600, }); @@ -117,6 +118,7 @@ describe("ensureCookies", () => { roles: ["user"], email: "test@example.com", phone: "+14155552671", + organizationId: "org-123", }); expect(refreshCookie.name).toBe("refresh"); }); @@ -226,4 +228,29 @@ describe("ensureCookies", () => { roles: ["user"], }); }); + + it("requires the access cookie for organization routes", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + verifyCookieJwtMock.mockReturnValue({ + sub: "user-123", + sessionId: "session-123", + roles: ["user"], + }); + + const result = await ensureCookies( + { + path: "/organizations/org-123/members", + cookies: { access: "valid.access.jwt" }, + }, + BASE_OPTS, + ); + + expect(result.type).toBe("ok"); + expect(result.user).toEqual({ + sub: "user-123", + sessionId: "session-123", + roles: ["user"], + }); + }); }); diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 1bf6c57..3c8fd59 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -9,6 +9,7 @@ import { finishLogin } from "./handlers/finishLogin"; import { register } from "./handlers/register"; import { requestOtp } from "./handlers/requestOtp"; import { verifyLoginOtp } from "./handlers/verifyLoginOtp"; +import { switchOrganization } from "./handlers/switchOrganization"; import { finishRegister } from "./handlers/finishRegister"; import { me } from "./handlers/me"; import { logout } from "./handlers/logout"; @@ -99,6 +100,11 @@ function buildProxyQueryString(queryInput: Request["query"]): string { return query.toString(); } +function routeParam(req: Request, name: string): string { + const value = req.params[name]; + return Array.isArray(value) ? value[0] : value; +} + /** * Creates an Express Router that proxies all authentication traffic to a Seamless Auth server. * @@ -175,7 +181,7 @@ export function createSeamlessAuthServer( const proxyWithIdentity = ( - path: string, + path: string | ((req: Request) => string), identity: "preAuth" | "access" | "register", method: AuthFetchOptions["method"] = "POST", ) => @@ -221,8 +227,9 @@ export function createSeamlessAuthServer( : { method, authorization, forwardedClientIp, body: req.body }; const queryString = buildProxyQueryString(req.query); + const resolvedPath = typeof path === "function" ? path(req) : path; const upstream = await authFetch( - `${resolvedOpts.authServerUrl}/${path}${queryString ? `?${queryString}` : ""}`, + `${resolvedOpts.authServerUrl}/${resolvedPath}${queryString ? `?${queryString}` : ""}`, options as any, ); @@ -299,6 +306,61 @@ export function createSeamlessAuthServer( r.get("/users/me", (req, res) => me(req, res, resolvedOpts)); r.get("/logout", (req, res) => logout(req, res, resolvedOpts)); + r.get("/organizations", proxyWithIdentity("organizations", "access", "GET")); + r.post("/organizations", proxyWithIdentity("organizations", "access")); + r.get( + "/organizations/:organizationId", + proxyWithIdentity( + req => `organizations/${encodeURIComponent(routeParam(req, "organizationId"))}`, + "access", + "GET", + ), + ); + r.patch( + "/organizations/:organizationId", + proxyWithIdentity( + req => `organizations/${encodeURIComponent(routeParam(req, "organizationId"))}`, + "access", + "PATCH", + ), + ); + r.post("/organizations/:organizationId/switch", (req, res) => + switchOrganization(req, res, resolvedOpts), + ); + r.get( + "/organizations/:organizationId/members", + proxyWithIdentity( + req => `organizations/${encodeURIComponent(routeParam(req, "organizationId"))}/members`, + "access", + "GET", + ), + ); + r.post( + "/organizations/:organizationId/members", + proxyWithIdentity( + req => `organizations/${encodeURIComponent(routeParam(req, "organizationId"))}/members`, + "access", + ), + ); + r.patch( + "/organizations/:organizationId/members/:userId", + proxyWithIdentity( + req => + `organizations/${encodeURIComponent(routeParam(req, "organizationId"))}/members/${encodeURIComponent(routeParam(req, "userId"))}`, + "access", + "PATCH", + ), + ); + r.delete( + "/organizations/:organizationId/members/:userId", + proxyWithIdentity( + req => + `organizations/${encodeURIComponent(routeParam(req, "organizationId"))}/members/${encodeURIComponent(routeParam(req, "userId"))}`, + "access", + "DELETE", + ), + ); + r.get( "/step-up/status", proxyWithIdentity("step-up/status", "access", "GET"), @@ -400,6 +462,66 @@ export function createSeamlessAuthServer( admin.getCredentialCount(req, res, resolvedOpts), ); + r.get( + "/admin/organizations", + proxyWithIdentity("admin/organizations", "access", "GET"), + ); + r.post( + "/admin/organizations", + proxyWithIdentity("admin/organizations", "access"), + ); + r.get( + "/admin/organizations/:organizationId", + proxyWithIdentity( + req => `admin/organizations/${encodeURIComponent(routeParam(req, "organizationId"))}`, + "access", + "GET", + ), + ); + r.patch( + "/admin/organizations/:organizationId", + proxyWithIdentity( + req => `admin/organizations/${encodeURIComponent(routeParam(req, "organizationId"))}`, + "access", + "PATCH", + ), + ); + r.get( + "/admin/organizations/:organizationId/members", + proxyWithIdentity( + req => + `admin/organizations/${encodeURIComponent(routeParam(req, "organizationId"))}/members`, + "access", + "GET", + ), + ); + r.post( + "/admin/organizations/:organizationId/members", + proxyWithIdentity( + req => + `admin/organizations/${encodeURIComponent(routeParam(req, "organizationId"))}/members`, + "access", + ), + ); + r.patch( + "/admin/organizations/:organizationId/members/:userId", + proxyWithIdentity( + req => + `admin/organizations/${encodeURIComponent(routeParam(req, "organizationId"))}/members/${encodeURIComponent(routeParam(req, "userId"))}`, + "access", + "PATCH", + ), + ); + r.delete( + "/admin/organizations/:organizationId/members/:userId", + proxyWithIdentity( + req => + `admin/organizations/${encodeURIComponent(routeParam(req, "organizationId"))}/members/${encodeURIComponent(routeParam(req, "userId"))}`, + "access", + "DELETE", + ), + ); + r.get("/admin/sessions", (req, res) => admin.listAllSessions(req, res, resolvedOpts), ); diff --git a/packages/express/src/handlers/switchOrganization.ts b/packages/express/src/handlers/switchOrganization.ts new file mode 100644 index 0000000..2e0263f --- /dev/null +++ b/packages/express/src/handlers/switchOrganization.ts @@ -0,0 +1,64 @@ +import { Request, Response } from "express"; +import { switchOrganizationHandler } from "@seamless-auth/core/handlers/switchOrganizationHandler"; +import { setSessionCookie } from "../internal/cookie"; +import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; +import { SeamlessAuthServerOptions } from "../createServer"; + +function routeParam(req: Request, name: string): string { + const value = req.params[name]; + return Array.isArray(value) ? value[0] : value; +} + +export async function switchOrganization( + req: Request & { cookiePayload?: any }, + res: Response, + opts: SeamlessAuthServerOptions, +) { + const cookieSigner = { + secret: opts.cookieSecret, + secure: process.env.NODE_ENV === "production", + sameSite: + process.env.NODE_ENV === "production" + ? "none" + : ("lax" as "none" | "lax"), + }; + + const result = await switchOrganizationHandler( + { + organizationId: routeParam(req, "organizationId"), + authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), + }, + { + authServerUrl: opts.authServerUrl, + cookieDomain: opts.cookieDomain, + accessCookieName: opts.accessCookieName!, + }, + ); + + if (!cookieSigner.secret) { + throw new Error("Missing COOKIE_SIGNING_KEY"); + } + + if (result.setCookies) { + for (const c of result.setCookies) { + setSessionCookie( + res, + { + name: c.name, + payload: c.value, + domain: c.domain, + ttlSeconds: c.ttl, + }, + cookieSigner, + ); + } + } + + if (result.error) { + return res.status(result.status).json(result.error); + } + + return res.status(result.status).json(result.body); +} diff --git a/packages/express/tests/organizationRoutes.test.js b/packages/express/tests/organizationRoutes.test.js new file mode 100644 index 0000000..c376605 --- /dev/null +++ b/packages/express/tests/organizationRoutes.test.js @@ -0,0 +1,116 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +const { default: createSeamlessAuthServer } = await import("../dist/index.js"); + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function createAccessCookie(subject = "user-123") { + const token = jwt.sign( + { sub: subject, roles: ["admin"], sessionId: "session-123" }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); + + return `seamless-access=${token}`; +} + +function createApp() { + const app = express(); + + app.use( + "/auth", + createSeamlessAuthServer({ + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret", + serviceSecret: "service-secret", + issuer: "https://api.example.com", + audience: "https://auth.example.com", + jwksKid: "dev-main", + }), + ); + + return app; +} + +describe("organization proxy routes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("proxies organization listing with access identity", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + organizations: [{ id: "org-1", name: "Acme", slug: "acme" }], + total: 1, + }), + ); + + const res = await request(createApp()) + .get("/auth/admin/organizations") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + organizations: [{ id: "org-1", name: "Acme", slug: "acme" }], + total: 1, + }); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/admin/organizations", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Bearer /), + "x-seamless-service-token": expect.stringMatching(/^Bearer /), + }), + }), + ); + }); + + it("proxies organization member writes with path params and body", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + membership: { + id: "membership-1", + userId: "user-2", + organizationId: "org-1", + roles: ["admin"], + scopes: ["members:write"], + }, + }), + ); + + const body = { roles: ["admin"], scopes: ["members:write"] }; + + const res = await request(createApp()) + .patch("/auth/admin/organizations/org-1/members/user-2") + .set("Cookie", createAccessCookie()) + .send(body); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/admin/organizations/org-1/members/user-2", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify(body), + }), + ); + }); +});