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
2 changes: 1 addition & 1 deletion apps/cloud/src/account/org-api-key-revoke.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ const providerWith = (accountId: string) => {
stubDirectory,
stubApiKeys,
stubAutumn,
Layer.succeed(AccountCaller)({ session: session(accountId) }),
Layer.succeed(AccountCaller)({ session: session(accountId), adminVerified: false }),
),
),
),
Expand Down
143 changes: 143 additions & 0 deletions apps/cloud/src/auth/admin-mfa-proof.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { SignJWT } from "jose";
import { readAdminMfaProof, signAdminMfaProof } from "./admin-mfa-proof";

const secret = "a-test-only-cookie-password-of-32-characters";
const identity = { userId: "user_test", sessionId: "session_test" };
const now = 1_800_000_000_000;
const proof = {
mode: "challenge" as const,
factorId: "factor_test",
challengeId: "challenge_test",
exp: now / 1000 + 900,
};
const signed = signAdminMfaProof(secret, identity, "verified", proof, now);

describe("admin verification cookie", () => {
it.effect("accepts a valid proof for the same user and session", () =>
Effect.gen(function* () {
const token = yield* signed;
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toEqual(proof);
}),
);

it.effect("refuses missing, modified, and unsigned cookies", () =>
Effect.gen(function* () {
const token = yield* signed;
const parts = token.split(".");
const unsigned = `${btoa('{"alg":"none"}')}.${parts[1]}.`;
for (const value of [
undefined,
"",
"bad.cookie",
`${token.slice(0, 50)}x${token.slice(51)}`,
unsigned,
]) {
expect(yield* readAdminMfaProof(secret, identity, "verified", value, now)).toBeNull();
}
}),
);

it.effect("refuses another session, another user, and another signing key", () =>
Effect.gen(function* () {
const token = yield* signed;
for (const other of [
{ ...identity, userId: "other" },
{ ...identity, sessionId: "other" },
]) {
expect(yield* readAdminMfaProof(secret, other, "verified", token, now)).toBeNull();
}
expect(
yield* readAdminMfaProof(`${secret}-rotated`, identity, "verified", token, now),
).toBeNull();
}),
);

it.effect("cannot promote an unfinished challenge to verified access", () =>
Effect.gen(function* () {
const token = yield* signAdminMfaProof(
secret,
identity,
"challenge",
{ ...proof, mode: "enroll", exp: now / 1000 + 300 },
now,
);
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull();
expect(
yield* readAdminMfaProof(secret, identity, "challenge", token, now + 299_000),
).not.toBeNull();
expect(
yield* readAdminMfaProof(secret, identity, "challenge", token, now + 300_000),
).toBeNull();
}),
);

it.effect("honors the signed expiration and refuses a future-issued cookie", () =>
Effect.gen(function* () {
const token = yield* signed;
expect(
yield* readAdminMfaProof(secret, identity, "verified", token, now + 899_000),
).not.toBeNull();
expect(
yield* readAdminMfaProof(secret, identity, "verified", token, now + 900_000),
).toBeNull();
expect(
yield* readAdminMfaProof(secret, identity, "verified", token, now - 10_000),
).toBeNull();
}),
);

it.effect("keeps the verified session unlocked beyond the former fifteen-minute window", () =>
Effect.gen(function* () {
const token = yield* signAdminMfaProof(
secret,
identity,
"verified",
{
...proof,
exp: now / 1000 + 7 * 86400,
},
now,
);
expect(
yield* readAdminMfaProof(secret, identity, "verified", token, now + 3600_000),
).not.toBeNull();
expect(
yield* readAdminMfaProof(secret, identity, "verified", token, now + 7 * 86400_000),
).toBeNull();
}),
);

it.effect("caps token age even when the supplied expiration is longer", () =>
Effect.gen(function* () {
const token = yield* signAdminMfaProof(
secret,
identity,
"verified",
{ ...proof, exp: now / 1000 + 8 * 86400 },
now,
);
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull();
expect(
yield* readAdminMfaProof(secret, identity, "verified", token, now + 901_000),
).toBeNull();
}),
);

it.effect("rejects a signed cookie with missing issued-at or another algorithm", () =>
Effect.gen(function* () {
for (const algorithm of ["HS256", "HS384"]) {
const jwt = new SignJWT({ ...proof })
.setProtectedHeader({ alg: algorithm })
.setIssuer("executor:admin-mfa:verified")
.setSubject(identity.userId)
.setAudience(identity.sessionId);
// HS256 lacks iat; HS384 is otherwise valid but outside the allowlist.
if (algorithm === "HS384") jwt.setIssuedAt(now / 1000);
const token = yield* Effect.promise(() => jwt.sign(new TextEncoder().encode(secret)));
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull();
}
}),
);
});
24 changes: 13 additions & 11 deletions apps/cloud/src/auth/mirror-feeders.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { verifiedSettingsCookie } from "../../test-stubs/verified-settings";
// ---------------------------------------------------------------------------
// The membership mirror's FEEDERS, end to end through the code that runs in
// production, against the real PGlite Postgres every cloud unit test runs on
Expand Down Expand Up @@ -594,6 +595,7 @@ describe("session handlers read membership from the mirror", () => {
authenticateSealedSession: () =>
Effect.succeed({
userId,
sessionId: "test-settings-session",
email: `${userId}@placeholder.test`,
organizationId: null,
} as never),
Expand Down Expand Up @@ -710,11 +712,11 @@ describe("session handlers read membership from the mirror", () => {
return slug;
};

const deleteOrganizationRequest = (org: string) =>
const deleteOrganizationRequest = async (org: string, userId: string) =>
new Request("http://test.local/auth/delete-organization", {
method: "POST",
headers: {
cookie: "wos-session=sealed",
cookie: `wos-session=sealed; ${await verifiedSettingsCookie(userId)}`,
"content-type": "application/json",
[ORG_SELECTOR_HEADER]: org,
},
Expand Down Expand Up @@ -761,7 +763,7 @@ describe("session handlers read membership from the mirror", () => {
// requires an ACTIVE membership, so the invite grants no deletion right.
await seedMembership(userId, org, "pending", "admin");

const response = await sessionHandler(userId)(deleteOrganizationRequest(org));
const response = await sessionHandler(userId)(await deleteOrganizationRequest(org, userId));

// The selector resolves no active membership, so the request fails at the
// org check (NoOrganization) — the handler never reaches the WorkOS
Expand All @@ -775,7 +777,7 @@ describe("session handlers read membership from the mirror", () => {
const org = freshId("org");
await seedMembership(userId, org, "active", "member");

const response = await sessionHandler(userId)(deleteOrganizationRequest(org));
const response = await sessionHandler(userId)(await deleteOrganizationRequest(org, userId));

expect(response.status).toBe(403);
expect(
Expand Down Expand Up @@ -805,7 +807,7 @@ describe("session handlers read membership from the mirror", () => {
}),
},
});
const first = await failing(deleteOrganizationRequest(org));
const first = await failing(await deleteOrganizationRequest(org, admin));
expect(first.status, "the failed purge is surfaced, not hidden").toBe(500);
expect(workosDeletes).toEqual([org]);
expect(purges).toEqual(["deleteOrganizationCascade"]);
Expand All @@ -825,7 +827,7 @@ describe("session handlers read membership from the mirror", () => {
deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })),
},
});
const second = await retry(deleteOrganizationRequest(org));
const second = await retry(await deleteOrganizationRequest(org, admin));
expect(second.status, "the admin's own membership still admits the retry").toBe(200);
expect(await second.json()).toEqual({ success: true });
expect(
Expand Down Expand Up @@ -879,7 +881,7 @@ describe("session handlers read membership from the mirror", () => {
},
});

const first = await handler(deleteOrganizationRequest(org));
const first = await handler(await deleteOrganizationRequest(org, admin));
expect(first.status, "the failed billing cancel is surfaced, not hidden").toBe(500);
expect(await first.json()).toMatchObject({
_tag: "OrganizationDeletionIncomplete",
Expand All @@ -894,7 +896,7 @@ describe("session handlers read membership from the mirror", () => {
).toEqual([admin, member].sort());
expect(await authorized(member, org), "yet nobody is authorized: the mark stands").toBe(false);

const second = await handler(deleteOrganizationRequest(org));
const second = await handler(await deleteOrganizationRequest(org, admin));
expect(second.status, "the admin's own membership row still admits the retry").toBe(200);
expect(await second.json()).toEqual({ success: true });
expect(workosDeletes, "WorkOS is asked once billing is cancelled").toEqual([org]);
Expand All @@ -920,7 +922,7 @@ describe("session handlers read membership from the mirror", () => {
services: servicesWithFailingPurge(purges),
autumn: deletingAutumn,
workos: { deleteOrganization: () => Effect.void },
})(deleteOrganizationRequest(org));
})(await deleteOrganizationRequest(org, admin));
expect(first.status).toBe(500);
expect(purges).toEqual(["deleteOrganizationCascade"]);

Expand All @@ -933,7 +935,7 @@ describe("session handlers read membership from the mirror", () => {
deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })),
},
});
const second = await retry(deleteOrganizationRequest(org));
const second = await retry(await deleteOrganizationRequest(org, admin));
expect(second.status, "the retry is admitted from the mirror").toBe(200);
expect(await second.json()).toEqual({ success: true });
expect(await readMembers(org), "and the purge ran").toEqual([]);
Expand Down Expand Up @@ -1158,7 +1160,7 @@ describe("account service writes through to the mirror", () => {
workos,
stubApiKeys,
options.autumn ?? stubAutumn,
Layer.succeed(AccountCaller)({ session: session(ADMIN) }),
Layer.succeed(AccountCaller)({ session: session(ADMIN), adminVerified: true }),
),
),
Layer.provideMerge(stores),
Expand Down
7 changes: 6 additions & 1 deletion apps/cloud/src/org/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { verifiedSettingsCookie } from "../../test-stubs/verified-settings";
import { afterAll, describe, expect, it } from "@effect/vitest";
import { Data, Effect, Layer } from "effect";
import { HttpRouter, HttpServer } from "effect/unstable/http";
Expand Down Expand Up @@ -239,6 +240,7 @@ const workosForCaller = (deleted: string[]) =>
authenticateSealedSession: () =>
Effect.succeed({
userId: CALLER,
sessionId: "test-settings-session",
email: "caller@placeholder.test",
organizationId: ORG,
}),
Expand Down Expand Up @@ -274,7 +276,10 @@ const deleteDomain = async (role: "admin" | "member") => {
const response = await app.handler(
new Request(`https://executor.test/org/domains/${DOMAIN}`, {
method: "DELETE",
headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG },
headers: {
cookie: `wos-session=sealed; ${await verifiedSettingsCookie(CALLER)}`,
[ORG_SELECTOR_HEADER]: ORG,
},
}),
// beta.59: the handler type expects a context argument; this layer stack
// needs none at runtime — pass undefined like the api.request-scope tests.
Expand Down
23 changes: 23 additions & 0 deletions apps/cloud/test-stubs/verified-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { env } from "cloudflare:workers";
import { Effect } from "effect";
import { ADMIN_MFA_COOKIE, signAdminMfaProof } from "../src/auth/admin-mfa-proof";

/** A signed proof for HTTP fixtures; the WorkOS stub must return this session id. */
export const verifiedSettingsCookie = async (userId: string): Promise<string> => {
const now = Date.now();
const proof = await Effect.runPromise(
signAdminMfaProof(
env.WORKOS_COOKIE_PASSWORD,
{ userId, sessionId: "test-settings-session" },
"verified",
{
factorId: "test-factor",
challengeId: "test-challenge",
mode: "challenge",
exp: now / 1000 + 900,
},
now,
),
);
return `${ADMIN_MFA_COOKIE}=${proof}`;
};
Loading
Loading