Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface CookiePayload {
roles?: string[];
email?: string;
phone?: string | null;
organizationId?: string | null;
}

export interface CookieInstruction {
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -136,6 +138,10 @@ const COOKIE_REQUIREMENTS: Record<
name: "accessCookieName",
required: true,
},
"/admin/organizations": {
name: "accessCookieName",
required: true,
},

"/system-config/admin": {
name: "accessCookieName",
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/handlers/finishLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/handlers/switchOrganizationHandler.ts
Original file line number Diff line number Diff line change
@@ -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<SwitchOrganizationResult> {
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,
},
],
};
}
1 change: 1 addition & 0 deletions packages/core/src/handlers/verifyLoginOtpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
1 change: 1 addition & 0 deletions packages/core/src/refreshAccessToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type RefreshAccessTokenResult = {
roles?: string[];
email?: string;
phone?: string | null;
organizationId?: string | null;
ttl: number;
refreshTtl: number;
};
Expand Down
27 changes: 27 additions & 0 deletions packages/core/tests/ensureCookes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe("ensureCookies", () => {
roles: ["user"],
email: "test@example.com",
phone: "+14155552671",
organizationId: "org-123",
ttl: 300,
refreshTtl: 3600,
});
Expand All @@ -117,6 +118,7 @@ describe("ensureCookies", () => {
roles: ["user"],
email: "test@example.com",
phone: "+14155552671",
organizationId: "org-123",
});
expect(refreshCookie.name).toBe("refresh");
});
Expand Down Expand Up @@ -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"],
});
});
});
126 changes: 124 additions & 2 deletions packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -175,7 +181,7 @@ export function createSeamlessAuthServer(

const proxyWithIdentity =
(
path: string,
path: string | ((req: Request) => string),
identity: "preAuth" | "access" | "register",
method: AuthFetchOptions["method"] = "POST",
) =>
Expand Down Expand Up @@ -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,
);

Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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),
);
Expand Down
Loading
Loading