Skip to content

Commit 4a52307

Browse files
authored
Require MFA for organization settings (#2119)
1 parent 480b390 commit 4a52307

14 files changed

Lines changed: 736 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/cloud": patch
3+
---
4+
5+
Require authenticator verification for organization settings while preserving shared workspace reads and API-key access.

‎apps/cloud/src/account/account-api.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { env } from "cloudflare:workers";
2+
import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "../auth/admin-mfa-proof";
13
import { HttpRouter, HttpServerRequest } from "effect/unstable/http";
2-
import { Effect, Layer } from "effect";
4+
import { Clock, Effect, Layer } from "effect";
35

46
import {
57
AccountProvider,
@@ -75,6 +77,16 @@ const AccountProviderMiddleware = HttpRouter.middleware<{
7577
// session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie).
7678
const session: Session | null = resolved ? sessionFromSealed(resolved, "") : null;
7779

80+
const proof = resolved
81+
? yield* readAdminMfaProof(
82+
env.WORKOS_COOKIE_PASSWORD,
83+
{ userId: resolved.userId, sessionId: resolved.sessionId },
84+
"verified",
85+
request.cookies[ADMIN_MFA_COOKIE],
86+
yield* Clock.currentTimeMillis,
87+
)
88+
: null;
89+
7890
// Built inside the request body so the WorkOS account service closes
7991
// over the per-request `UserStoreService` (postgres socket) supplied by
8092
// the combined request-scoped layer. `local` keeps that promise: the
@@ -84,7 +96,7 @@ const AccountProviderMiddleware = HttpRouter.middleware<{
8496
AccountProvider.asEffect(),
8597
workosAccountProvider.pipe(
8698
Layer.provide(ApiKeyService.WorkOS),
87-
Layer.provide(Layer.succeed(AccountCaller)({ session })),
99+
Layer.provide(Layer.succeed(AccountCaller)({ session, adminVerified: proof !== null })),
88100
),
89101
{ local: true },
90102
);

‎apps/cloud/src/account/workos-account-service.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
// the same `WorkOSClient.authenticateSealedSession` the rest of cloud uses.
3232
export class AccountCaller extends Context.Service<
3333
AccountCaller,
34-
{ readonly session: Session | null }
34+
{ readonly session: Session | null; readonly adminVerified?: boolean }
3535
>()("@executor-js/cloud/AccountCaller") {}
3636

3737
// ---------------------------------------------------------------------------
@@ -146,6 +146,16 @@ export const workosAccountProvider: Layer.Layer<
146146
const requireAdmin = (org: { readonly memberRole: "admin" | "member" }) =>
147147
org.memberRole === "admin" ? Effect.void : Effect.fail(new AccountForbidden());
148148

149+
// Settings mutations require MFA; shared member reads and key management do not.
150+
const requireVerifiedSettings = (org: { readonly memberRole: "admin" | "member" }) =>
151+
Effect.gen(function* () {
152+
yield* requireAdmin(org);
153+
if (caller.adminVerified !== true)
154+
return yield* new AccountForbidden({
155+
message: "Verify your identity to change organization settings.",
156+
});
157+
});
158+
149159
// Ownership check so an admin can't mutate a membership id from another
150160
// org: the id must name a row the mirror holds for THIS org (any status —
151161
// revoking a pending invite is a delete too). One point read on the
@@ -390,7 +400,7 @@ export const workosAccountProvider: Layer.Layer<
390400
inviteMember: (headers, body) =>
391401
Effect.gen(function* () {
392402
const { org } = yield* requireOrganization(headers);
393-
yield* requireAdmin(org);
403+
yield* requireVerifiedSettings(org);
394404
yield* reserveMemberSlot(org.id);
395405
const invitation = yield* workos
396406
.sendInvitation({
@@ -422,7 +432,7 @@ export const workosAccountProvider: Layer.Layer<
422432
removeMember: (headers, membershipId) =>
423433
Effect.gen(function* () {
424434
const { org } = yield* requireOrganization(headers);
425-
yield* requireAdmin(org);
435+
yield* requireVerifiedSettings(org);
426436
const membership = yield* assertMembershipInOrg(org.id, membershipId);
427437
yield* workos
428438
.deleteOrgMembership(membershipId)
@@ -453,7 +463,7 @@ export const workosAccountProvider: Layer.Layer<
453463
updateMemberRole: (headers, membershipId, roleSlug) =>
454464
Effect.gen(function* () {
455465
const { org } = yield* requireOrganization(headers);
456-
yield* requireAdmin(org);
466+
yield* requireVerifiedSettings(org);
457467
yield* assertMembershipInOrg(org.id, membershipId);
458468
const updated = yield* workos
459469
.updateOrgMembershipRole(membershipId, roleSlug)
@@ -467,7 +477,7 @@ export const workosAccountProvider: Layer.Layer<
467477
updateOrgName: (headers, name) =>
468478
Effect.gen(function* () {
469479
const { org } = yield* requireOrganization(headers);
470-
yield* requireAdmin(org);
480+
yield* requireVerifiedSettings(org);
471481
const updated = yield* workos
472482
.updateOrganization(org.id, name)
473483
.pipe(Effect.catchTag("WorkOSError", toAccountError));

‎apps/cloud/src/api/router.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { UserStoreService } from "../auth/context";
1111
import { WorkOsMirror } from "../auth/workos-mirror";
1212
import { DbService } from "../db/db";
1313
import { makeAccountApiLive } from "../account/account-api";
14+
import { AdminMfaRoutes } from "../auth/admin-mfa-routes";
1415

1516
import { AutumnRoutesLive } from "../extensions/billing/route";
1617
import { CloudDocsLive } from "../extensions/docs";
@@ -41,6 +42,7 @@ export const makeApiLive = (
4142
Layer.provide(requestScopedMiddleware(requestScopedLive).layer),
4243
);
4344
return Layer.mergeAll(
45+
AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(requestScopedLive).layer)),
4446
makeNonProtectedApiLive(requestScopedLive),
4547
makeOrgApiLive(requestScopedLive),
4648
makeAccountApiLive(requestScopedLive),
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Data, Effect, Option, Schema } from "effect";
2+
import { SignJWT, jwtVerify } from "jose";
3+
4+
/** HttpOnly cookies used only for the administrative verification flow. */
5+
export const ADMIN_MFA_COOKIE = "__Host-executor-admin-mfa";
6+
/** The pending challenge is bound to the same user and WorkOS session. */
7+
export const ADMIN_MFA_CHALLENGE_COOKIE = "__Host-executor-admin-challenge";
8+
/** Verification is session-bound, with a seven-day maximum matching the login cookie. */
9+
export const ADMIN_MFA_TTL_SECONDS = 7 * 24 * 60 * 60;
10+
11+
/** A verified WorkOS session, supplied by the authentication adapter. */
12+
export interface AdminMfaIdentity {
13+
readonly userId: string;
14+
readonly sessionId: string;
15+
}
16+
17+
const Proof = Schema.Struct({
18+
factorId: Schema.String,
19+
challengeId: Schema.String,
20+
mode: Schema.Literals(["enroll", "challenge"]),
21+
exp: Schema.Number,
22+
});
23+
const decodeProof = Schema.decodeUnknownOption(Proof);
24+
25+
/** Signing failures are server failures; invalid input cookies are simply refused. */
26+
export class AdminMfaProofError extends Data.TaggedError("AdminMfaProofError")<{
27+
readonly cause: unknown;
28+
}> {}
29+
30+
type Purpose = "challenge" | "verified";
31+
const issuer = (purpose: Purpose) => `executor:admin-mfa:${purpose}`;
32+
const key = (secret: string) => new TextEncoder().encode(secret);
33+
34+
/** Sign a purpose-specific, session-bound proof with an explicit expiration. */
35+
export const signAdminMfaProof = (
36+
secret: string,
37+
identity: AdminMfaIdentity,
38+
purpose: Purpose,
39+
proof: typeof Proof.Type,
40+
now: number,
41+
) =>
42+
Effect.tryPromise({
43+
try: () =>
44+
new SignJWT({ factorId: proof.factorId, challengeId: proof.challengeId, mode: proof.mode })
45+
.setProtectedHeader({ alg: "HS256" })
46+
.setIssuer(issuer(purpose))
47+
.setSubject(identity.userId)
48+
.setAudience(identity.sessionId)
49+
.setIssuedAt(Math.floor(now / 1000))
50+
.setExpirationTime(proof.exp)
51+
.sign(key(secret)),
52+
catch: (cause) => new AdminMfaProofError({ cause }),
53+
});
54+
55+
/** Reject expired, tampered, cross-user, cross-session, and wrong-purpose proofs. */
56+
export const readAdminMfaProof = (
57+
secret: string,
58+
identity: AdminMfaIdentity,
59+
purpose: Purpose,
60+
token: string | undefined,
61+
now: number,
62+
) => {
63+
if (!token) return Effect.succeed(null);
64+
return Effect.tryPromise({
65+
try: () =>
66+
jwtVerify(token, key(secret), {
67+
algorithms: ["HS256"],
68+
issuer: issuer(purpose),
69+
subject: identity.userId,
70+
audience: identity.sessionId,
71+
requiredClaims: ["exp", "iat", "sub", "aud"],
72+
maxTokenAge: purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS,
73+
currentDate: new Date(now),
74+
}),
75+
catch: (cause) => new AdminMfaProofError({ cause }),
76+
}).pipe(
77+
Effect.map(({ payload }) => {
78+
const maxAge = purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS;
79+
if (
80+
typeof payload.iat !== "number" ||
81+
typeof payload.exp !== "number" ||
82+
payload.exp > payload.iat + maxAge
83+
)
84+
return null;
85+
return Option.getOrNull(decodeProof(payload));
86+
}),
87+
Effect.catchTag("AdminMfaProofError", () => Effect.succeed(null)),
88+
);
89+
};

0 commit comments

Comments
 (0)