Skip to content

Commit 30ef40d

Browse files
committed
Test organization settings MFA and ordinary access
1 parent 34f2acc commit 30ef40d

16 files changed

Lines changed: 743 additions & 27 deletions

‎apps/cloud/src/account/org-api-key-revoke.node.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ const providerWith = (accountId: string) => {
196196
stubDirectory,
197197
stubApiKeys,
198198
stubAutumn,
199-
Layer.succeed(AccountCaller)({ session: session(accountId) }),
199+
Layer.succeed(AccountCaller)({ session: session(accountId), adminVerified: false }),
200200
),
201201
),
202202
),
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
import { SignJWT } from "jose";
4+
import { readAdminMfaProof, signAdminMfaProof } from "./admin-mfa-proof";
5+
6+
const secret = "a-test-only-cookie-password-of-32-characters";
7+
const identity = { userId: "user_test", sessionId: "session_test" };
8+
const now = 1_800_000_000_000;
9+
const proof = {
10+
mode: "challenge" as const,
11+
factorId: "factor_test",
12+
challengeId: "challenge_test",
13+
exp: now / 1000 + 900,
14+
};
15+
const signed = signAdminMfaProof(secret, identity, "verified", proof, now);
16+
17+
describe("admin verification cookie", () => {
18+
it.effect("accepts a valid proof for the same user and session", () =>
19+
Effect.gen(function* () {
20+
const token = yield* signed;
21+
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toEqual(proof);
22+
}),
23+
);
24+
25+
it.effect("refuses missing, modified, and unsigned cookies", () =>
26+
Effect.gen(function* () {
27+
const token = yield* signed;
28+
const parts = token.split(".");
29+
const unsigned = `${btoa('{"alg":"none"}')}.${parts[1]}.`;
30+
for (const value of [
31+
undefined,
32+
"",
33+
"bad.cookie",
34+
`${token.slice(0, 50)}x${token.slice(51)}`,
35+
unsigned,
36+
]) {
37+
expect(yield* readAdminMfaProof(secret, identity, "verified", value, now)).toBeNull();
38+
}
39+
}),
40+
);
41+
42+
it.effect("refuses another session, another user, and another signing key", () =>
43+
Effect.gen(function* () {
44+
const token = yield* signed;
45+
for (const other of [
46+
{ ...identity, userId: "other" },
47+
{ ...identity, sessionId: "other" },
48+
]) {
49+
expect(yield* readAdminMfaProof(secret, other, "verified", token, now)).toBeNull();
50+
}
51+
expect(
52+
yield* readAdminMfaProof(`${secret}-rotated`, identity, "verified", token, now),
53+
).toBeNull();
54+
}),
55+
);
56+
57+
it.effect("cannot promote an unfinished challenge to verified access", () =>
58+
Effect.gen(function* () {
59+
const token = yield* signAdminMfaProof(
60+
secret,
61+
identity,
62+
"challenge",
63+
{ ...proof, mode: "enroll", exp: now / 1000 + 300 },
64+
now,
65+
);
66+
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull();
67+
expect(
68+
yield* readAdminMfaProof(secret, identity, "challenge", token, now + 299_000),
69+
).not.toBeNull();
70+
expect(
71+
yield* readAdminMfaProof(secret, identity, "challenge", token, now + 300_000),
72+
).toBeNull();
73+
}),
74+
);
75+
76+
it.effect("honors the signed expiration and refuses a future-issued cookie", () =>
77+
Effect.gen(function* () {
78+
const token = yield* signed;
79+
expect(
80+
yield* readAdminMfaProof(secret, identity, "verified", token, now + 899_000),
81+
).not.toBeNull();
82+
expect(
83+
yield* readAdminMfaProof(secret, identity, "verified", token, now + 900_000),
84+
).toBeNull();
85+
expect(
86+
yield* readAdminMfaProof(secret, identity, "verified", token, now - 10_000),
87+
).toBeNull();
88+
}),
89+
);
90+
91+
it.effect("keeps the verified session unlocked beyond the former fifteen-minute window", () =>
92+
Effect.gen(function* () {
93+
const token = yield* signAdminMfaProof(
94+
secret,
95+
identity,
96+
"verified",
97+
{
98+
...proof,
99+
exp: now / 1000 + 7 * 86400,
100+
},
101+
now,
102+
);
103+
expect(
104+
yield* readAdminMfaProof(secret, identity, "verified", token, now + 3600_000),
105+
).not.toBeNull();
106+
expect(
107+
yield* readAdminMfaProof(secret, identity, "verified", token, now + 7 * 86400_000),
108+
).toBeNull();
109+
}),
110+
);
111+
112+
it.effect("caps token age even when the supplied expiration is longer", () =>
113+
Effect.gen(function* () {
114+
const token = yield* signAdminMfaProof(
115+
secret,
116+
identity,
117+
"verified",
118+
{ ...proof, exp: now / 1000 + 8 * 86400 },
119+
now,
120+
);
121+
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull();
122+
expect(
123+
yield* readAdminMfaProof(secret, identity, "verified", token, now + 901_000),
124+
).toBeNull();
125+
}),
126+
);
127+
128+
it.effect("rejects a signed cookie with missing issued-at or another algorithm", () =>
129+
Effect.gen(function* () {
130+
for (const algorithm of ["HS256", "HS384"]) {
131+
const jwt = new SignJWT({ ...proof })
132+
.setProtectedHeader({ alg: algorithm })
133+
.setIssuer("executor:admin-mfa:verified")
134+
.setSubject(identity.userId)
135+
.setAudience(identity.sessionId);
136+
// HS256 lacks iat; HS384 is otherwise valid but outside the allowlist.
137+
if (algorithm === "HS384") jwt.setIssuedAt(now / 1000);
138+
const token = yield* Effect.promise(() => jwt.sign(new TextEncoder().encode(secret)));
139+
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull();
140+
}
141+
}),
142+
);
143+
});

‎apps/cloud/src/auth/mirror-feeders.node.test.ts‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { verifiedSettingsCookie } from "../../test-stubs/verified-settings";
12
// ---------------------------------------------------------------------------
23
// The membership mirror's FEEDERS, end to end through the code that runs in
34
// production, against the real PGlite Postgres every cloud unit test runs on
@@ -594,6 +595,7 @@ describe("session handlers read membership from the mirror", () => {
594595
authenticateSealedSession: () =>
595596
Effect.succeed({
596597
userId,
598+
sessionId: "test-settings-session",
597599
email: `${userId}@placeholder.test`,
598600
organizationId: null,
599601
} as never),
@@ -710,11 +712,11 @@ describe("session handlers read membership from the mirror", () => {
710712
return slug;
711713
};
712714

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

764-
const response = await sessionHandler(userId)(deleteOrganizationRequest(org));
766+
const response = await sessionHandler(userId)(await deleteOrganizationRequest(org, userId));
765767

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

778-
const response = await sessionHandler(userId)(deleteOrganizationRequest(org));
780+
const response = await sessionHandler(userId)(await deleteOrganizationRequest(org, userId));
779781

780782
expect(response.status).toBe(403);
781783
expect(
@@ -805,7 +807,7 @@ describe("session handlers read membership from the mirror", () => {
805807
}),
806808
},
807809
});
808-
const first = await failing(deleteOrganizationRequest(org));
810+
const first = await failing(await deleteOrganizationRequest(org, admin));
809811
expect(first.status, "the failed purge is surfaced, not hidden").toBe(500);
810812
expect(workosDeletes).toEqual([org]);
811813
expect(purges).toEqual(["deleteOrganizationCascade"]);
@@ -825,7 +827,7 @@ describe("session handlers read membership from the mirror", () => {
825827
deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })),
826828
},
827829
});
828-
const second = await retry(deleteOrganizationRequest(org));
830+
const second = await retry(await deleteOrganizationRequest(org, admin));
829831
expect(second.status, "the admin's own membership still admits the retry").toBe(200);
830832
expect(await second.json()).toEqual({ success: true });
831833
expect(
@@ -879,7 +881,7 @@ describe("session handlers read membership from the mirror", () => {
879881
},
880882
});
881883

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

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

@@ -933,7 +935,7 @@ describe("session handlers read membership from the mirror", () => {
933935
deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })),
934936
},
935937
});
936-
const second = await retry(deleteOrganizationRequest(org));
938+
const second = await retry(await deleteOrganizationRequest(org, admin));
937939
expect(second.status, "the retry is admitted from the mirror").toBe(200);
938940
expect(await second.json()).toEqual({ success: true });
939941
expect(await readMembers(org), "and the purge ran").toEqual([]);
@@ -1158,7 +1160,7 @@ describe("account service writes through to the mirror", () => {
11581160
workos,
11591161
stubApiKeys,
11601162
options.autumn ?? stubAutumn,
1161-
Layer.succeed(AccountCaller)({ session: session(ADMIN) }),
1163+
Layer.succeed(AccountCaller)({ session: session(ADMIN), adminVerified: true }),
11621164
),
11631165
),
11641166
Layer.provideMerge(stores),

‎apps/cloud/src/org/handlers.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { verifiedSettingsCookie } from "../../test-stubs/verified-settings";
12
import { afterAll, describe, expect, it } from "@effect/vitest";
23
import { Data, Effect, Layer } from "effect";
34
import { HttpRouter, HttpServer } from "effect/unstable/http";
@@ -239,6 +240,7 @@ const workosForCaller = (deleted: string[]) =>
239240
authenticateSealedSession: () =>
240241
Effect.succeed({
241242
userId: CALLER,
243+
sessionId: "test-settings-session",
242244
email: "caller@placeholder.test",
243245
organizationId: ORG,
244246
}),
@@ -274,7 +276,10 @@ const deleteDomain = async (role: "admin" | "member") => {
274276
const response = await app.handler(
275277
new Request(`https://executor.test/org/domains/${DOMAIN}`, {
276278
method: "DELETE",
277-
headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG },
279+
headers: {
280+
cookie: `wos-session=sealed; ${await verifiedSettingsCookie(CALLER)}`,
281+
[ORG_SELECTOR_HEADER]: ORG,
282+
},
278283
}),
279284
// beta.59: the handler type expects a context argument; this layer stack
280285
// needs none at runtime — pass undefined like the api.request-scope tests.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { env } from "cloudflare:workers";
2+
import { Effect } from "effect";
3+
import { ADMIN_MFA_COOKIE, signAdminMfaProof } from "../src/auth/admin-mfa-proof";
4+
5+
/** A signed proof for HTTP fixtures; the WorkOS stub must return this session id. */
6+
export const verifiedSettingsCookie = async (userId: string): Promise<string> => {
7+
const now = Date.now();
8+
const proof = await Effect.runPromise(
9+
signAdminMfaProof(
10+
env.WORKOS_COOKIE_PASSWORD,
11+
{ userId, sessionId: "test-settings-session" },
12+
"verified",
13+
{
14+
factorId: "test-factor",
15+
challengeId: "test-challenge",
16+
mode: "challenge",
17+
exp: now / 1000 + 900,
18+
},
19+
now,
20+
),
21+
);
22+
return `${ADMIN_MFA_COOKIE}=${proof}`;
23+
};

0 commit comments

Comments
 (0)