From 8ed09853f56651e59f5f94b10c788577f02e2999 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:29:25 -0700 Subject: [PATCH 1/3] Require MFA for cloud workspace admins --- .changeset/admin-key-verification.md | 5 + apps/cloud/src/account/account-api.ts | 3 +- .../account/org-api-key-revoke.node.test.ts | 1 + .../src/account/workos-account-service.ts | 13 +- .../src/admin/admin-users-api.node.test.ts | 21 +- apps/cloud/src/admin/admin-users-api.ts | 1 + apps/cloud/src/api/router.ts | 2 + apps/cloud/src/auth/access-token-options.ts | 1 + apps/cloud/src/auth/admin-mfa-proof.test.ts | 122 +++++++++ apps/cloud/src/auth/admin-mfa-proof.ts | 89 +++++++ apps/cloud/src/auth/admin-mfa-routes.ts | 227 ++++++++++++++++ apps/cloud/src/auth/handlers.ts | 6 +- apps/cloud/src/auth/middleware-live.ts | 8 +- apps/cloud/src/auth/middleware.ts | 4 + .../src/auth/mirror-feeders.node.test.ts | 3 + .../src/auth/org-selector-auth.node.test.ts | 1 + apps/cloud/src/auth/workos-auth-provider.ts | 9 + apps/cloud/src/auth/workos.node.test.ts | 2 + apps/cloud/src/auth/workos.ts | 69 ++++- apps/cloud/src/env-augment.d.ts | 6 + apps/cloud/src/extensions/billing/route.ts | 7 + apps/cloud/src/extensions/routes.ts | 2 + apps/cloud/src/org/auth-middleware.ts | 10 +- apps/cloud/src/org/handlers.test.ts | 9 +- apps/cloud/src/org/handlers.ts | 4 +- .../src/web/components/admin-verification.tsx | 252 ++++++++++++++++++ apps/cloud/src/web/shell.tsx | 10 +- apps/cloud/wrangler.jsonc | 8 + bun.lock | 59 +++- e2e/cloud/admin-mfa-api.test.ts | 121 +++++++++ e2e/cloud/admin-mfa.test.ts | 99 +++++++ e2e/cloud/auth-hint.test.ts | 4 + e2e/cloud/support/admin-mfa.ts | 77 ++++++ e2e/cloud/support/session.ts | 13 +- e2e/package.json | 3 +- e2e/src/surfaces/browser.ts | 11 +- e2e/src/target.ts | 11 +- e2e/targets/cloud.ts | 8 +- packages/core/api/src/account/api.ts | 2 +- packages/core/api/src/account/service.ts | 6 +- 40 files changed, 1275 insertions(+), 34 deletions(-) create mode 100644 .changeset/admin-key-verification.md create mode 100644 apps/cloud/src/auth/admin-mfa-proof.test.ts create mode 100644 apps/cloud/src/auth/admin-mfa-proof.ts create mode 100644 apps/cloud/src/auth/admin-mfa-routes.ts create mode 100644 apps/cloud/src/web/components/admin-verification.tsx create mode 100644 e2e/cloud/admin-mfa-api.test.ts create mode 100644 e2e/cloud/admin-mfa.test.ts create mode 100644 e2e/cloud/support/admin-mfa.ts diff --git a/.changeset/admin-key-verification.md b/.changeset/admin-key-verification.md new file mode 100644 index 0000000000..d4779f6c98 --- /dev/null +++ b/.changeset/admin-key-verification.md @@ -0,0 +1,5 @@ +--- +"@executor-js/api": patch +--- + +Allow account providers to require extra verification before issuing a user API key. diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts index d9aaf70d27..898fe3a178 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -13,6 +13,7 @@ import { UserStoreService } from "../auth/context"; import { WorkOsMirror } from "../auth/workos-mirror"; import { sessionFromSealed, type Session } from "../auth/middleware"; import { WorkOSClient } from "../auth/workos"; +import { ADMIN_MFA_COOKIE } from "../auth/admin-mfa-proof"; import { AutumnService } from "../extensions/billing/service"; import { DbService } from "../db/db"; import { AccountCaller, workosAccountProvider } from "./workos-account-service"; @@ -69,7 +70,7 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ const request = yield* HttpServerRequest.HttpServerRequest; const cookieValue = request.cookies["wos-session"] ?? ""; const resolved = yield* workos - .authenticateSealedSession(cookieValue) + .authenticateSealedSession(cookieValue, request.cookies[ADMIN_MFA_COOKIE]) .pipe(Effect.orElseSucceed(() => null)); // The account API never re-sets the cookie, so the fallback sealed // session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie). diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index 48db9c2df3..1c80219263 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -59,6 +59,7 @@ const session = (accountId: string) => ({ name: null, avatarUrl: null, organizationId: ORG, + adminVerified: true, sealedSession: "sealed", refreshedSession: null, }); diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts index 94f5aed511..18e9d4b69c 100644 --- a/apps/cloud/src/account/workos-account-service.ts +++ b/apps/cloud/src/account/workos-account-service.ts @@ -144,7 +144,15 @@ export const workosAccountProvider: Layer.Layer< // moments ago is denied as soon as the write-through or the Events // reconciler has landed the change. const requireAdmin = (org: { readonly memberRole: "admin" | "member" }) => - org.memberRole === "admin" ? Effect.void : Effect.fail(new AccountForbidden()); + Effect.gen(function* () { + if (org.memberRole !== "admin") return yield* new AccountForbidden(); + const session = yield* requireSession(); + if (session.adminVerified !== true) { + return yield* new AccountForbidden({ + message: "Verify your identity to use organization admin settings.", + }); + } + }); // Ownership check so an admin can't mutate a membership id from another // org: the id must name a row the mirror holds for THIS org (any status — @@ -259,6 +267,9 @@ export const workosAccountProvider: Layer.Layer< createApiKey: (headers, name) => Effect.gen(function* () { const { session, org } = yield* requireOrganization(headers); + // An admin's personal key inherits workspace write permission. + // Do not let an unverified browser session mint that credential. + if (org.memberRole === "admin") yield* requireAdmin(org); const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH); if (!trimmed) { return yield* new AccountError({ diff --git a/apps/cloud/src/admin/admin-users-api.node.test.ts b/apps/cloud/src/admin/admin-users-api.node.test.ts index 68f796bea3..d46ea9a210 100644 --- a/apps/cloud/src/admin/admin-users-api.node.test.ts +++ b/apps/cloud/src/admin/admin-users-api.node.test.ts @@ -135,7 +135,7 @@ const stubMirror = Layer.succeed( // Only session authentication is served; membership is read from the mirror, // so any other WorkOS call fails the test. -const stubWorkOS = (userId: string) => +const stubWorkOS = (userId: string, adminVerified: boolean) => Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { @@ -144,6 +144,7 @@ const stubWorkOS = (userId: string) => return () => Effect.succeed({ userId, + adminVerified, email: `${userId}@placeholder.test`, organizationId: null, }); @@ -153,14 +154,20 @@ const stubWorkOS = (userId: string) => }), ); -const authorizeAs = (userId: string) => +const authorizeAs = (userId: string, adminVerified = true) => authorizeTenant( new Request("https://admin.invalid", { headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG }, }), ).pipe( Effect.provide( - Layer.mergeAll(stubDirectory, stubApiKeys, stubUsers, stubWorkOS(userId), stubMirror), + Layer.mergeAll( + stubDirectory, + stubApiKeys, + stubUsers, + stubWorkOS(userId, adminVerified), + stubMirror, + ), ), ); @@ -172,6 +179,14 @@ describe("authorizeTenant · admin session", () => { }), ); + it.effect("an admin without a second factor is forbidden", () => + Effect.gen(function* () { + expect(yield* Effect.flip(authorizeAs("user_admin", false))).toBeInstanceOf( + AdminUsersForbidden, + ); + }), + ); + it.effect("an active plain member is forbidden", () => Effect.gen(function* () { const error = yield* Effect.flip(authorizeAs("user_member")); diff --git a/apps/cloud/src/admin/admin-users-api.ts b/apps/cloud/src/admin/admin-users-api.ts index 0c77c03a93..5ce55b2827 100644 --- a/apps/cloud/src/admin/admin-users-api.ts +++ b/apps/cloud/src/admin/admin-users-api.ts @@ -115,6 +115,7 @@ export const authorizeTenant = ( ); if (!org) return yield* new AdminUsersForbidden(); if (org.memberRole !== "admin") return yield* new AdminUsersForbidden(); + if (session.adminVerified !== true) return yield* new AdminUsersForbidden(); return org.id; }); diff --git a/apps/cloud/src/api/router.ts b/apps/cloud/src/api/router.ts index 8c80825ef2..5851158f26 100644 --- a/apps/cloud/src/api/router.ts +++ b/apps/cloud/src/api/router.ts @@ -11,6 +11,7 @@ import { UserStoreService } from "../auth/context"; import { WorkOsMirror } from "../auth/workos-mirror"; import { DbService } from "../db/db"; import { makeAccountApiLive } from "../account/account-api"; +import { AdminMfaRoutes } from "../auth/admin-mfa-routes"; import { AutumnRoutesLive } from "../extensions/billing/route"; import { CloudDocsLive } from "../extensions/docs"; @@ -41,6 +42,7 @@ export const makeApiLive = ( Layer.provide(requestScopedMiddleware(requestScopedLive).layer), ); return Layer.mergeAll( + AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(requestScopedLive).layer)), makeNonProtectedApiLive(requestScopedLive), makeOrgApiLive(requestScopedLive), makeAccountApiLive(requestScopedLive), diff --git a/apps/cloud/src/auth/access-token-options.ts b/apps/cloud/src/auth/access-token-options.ts index d67915ce70..94e52d0adf 100644 --- a/apps/cloud/src/auth/access-token-options.ts +++ b/apps/cloud/src/auth/access-token-options.ts @@ -6,5 +6,6 @@ import type { JWTVerifyOptions } from "jose"; * that live for several days. */ export const workosAccessTokenOptions: JWTVerifyOptions = { + algorithms: ["RS256"], requiredClaims: ["exp", "iat"], }; diff --git a/apps/cloud/src/auth/admin-mfa-proof.test.ts b/apps/cloud/src/auth/admin-mfa-proof.test.ts new file mode 100644 index 0000000000..77adc7538f --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-proof.test.ts @@ -0,0 +1,122 @@ +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("expires at fifteen minutes 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("caps token age even when the supplied expiration is longer", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "verified", + { ...proof, exp: now / 1000 + 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(); + } + }), + ); +}); diff --git a/apps/cloud/src/auth/admin-mfa-proof.ts b/apps/cloud/src/auth/admin-mfa-proof.ts new file mode 100644 index 0000000000..c174ae59a2 --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-proof.ts @@ -0,0 +1,89 @@ +import { Data, Effect, Option, Schema } from "effect"; +import { SignJWT, jwtVerify } from "jose"; + +/** HttpOnly cookies used only for the administrative verification flow. */ +export const ADMIN_MFA_COOKIE = "__Host-executor-admin-mfa"; +/** The pending challenge is bound to the same user and WorkOS session. */ +export const ADMIN_MFA_CHALLENGE_COOKIE = "__Host-executor-admin-challenge"; +/** Administrative verification expires after fifteen minutes. */ +export const ADMIN_MFA_TTL_SECONDS = 15 * 60; + +/** A verified WorkOS session, supplied by the authentication adapter. */ +export interface AdminMfaIdentity { + readonly userId: string; + readonly sessionId: string; +} + +const Proof = Schema.Struct({ + factorId: Schema.String, + challengeId: Schema.String, + mode: Schema.Literals(["enroll", "challenge"]), + exp: Schema.Number, +}); +const decodeProof = Schema.decodeUnknownOption(Proof); + +/** Signing failures are server failures; invalid input cookies are simply refused. */ +export class AdminMfaProofError extends Data.TaggedError("AdminMfaProofError")<{ + readonly cause: unknown; +}> {} + +type Purpose = "challenge" | "verified"; +const issuer = (purpose: Purpose) => `executor:admin-mfa:${purpose}`; +const key = (secret: string) => new TextEncoder().encode(secret); + +/** Sign a purpose-specific, session-bound proof with an explicit expiration. */ +export const signAdminMfaProof = ( + secret: string, + identity: AdminMfaIdentity, + purpose: Purpose, + proof: typeof Proof.Type, + now: number, +) => + Effect.tryPromise({ + try: () => + new SignJWT({ factorId: proof.factorId, challengeId: proof.challengeId, mode: proof.mode }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer(issuer(purpose)) + .setSubject(identity.userId) + .setAudience(identity.sessionId) + .setIssuedAt(Math.floor(now / 1000)) + .setExpirationTime(proof.exp) + .sign(key(secret)), + catch: (cause) => new AdminMfaProofError({ cause }), + }); + +/** Reject expired, tampered, cross-user, cross-session, and wrong-purpose proofs. */ +export const readAdminMfaProof = ( + secret: string, + identity: AdminMfaIdentity, + purpose: Purpose, + token: string | undefined, + now: number, +) => { + if (!token) return Effect.succeed(null); + return Effect.tryPromise({ + try: () => + jwtVerify(token, key(secret), { + algorithms: ["HS256"], + issuer: issuer(purpose), + subject: identity.userId, + audience: identity.sessionId, + requiredClaims: ["exp", "iat", "sub", "aud"], + maxTokenAge: purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS, + currentDate: new Date(now), + }), + catch: (cause) => new AdminMfaProofError({ cause }), + }).pipe( + Effect.map(({ payload }) => { + const maxAge = purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS; + if ( + typeof payload.iat !== "number" || + typeof payload.exp !== "number" || + payload.exp > payload.iat + maxAge + ) + return null; + return Option.getOrNull(decodeProof(payload)); + }), + Effect.catchTag("AdminMfaProofError", () => Effect.succeed(null)), + ); +}; diff --git a/apps/cloud/src/auth/admin-mfa-routes.ts b/apps/cloud/src/auth/admin-mfa-routes.ts new file mode 100644 index 0000000000..fb2b792f18 --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-routes.ts @@ -0,0 +1,227 @@ +import { env } from "cloudflare:workers"; +import { Clock, Data, Duration, Effect, Layer, Option, Schema, Stream } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { WorkOSClient } from "./workos"; +import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "./organization"; +import { + ADMIN_MFA_COOKIE, + ADMIN_MFA_CHALLENGE_COOKIE, + ADMIN_MFA_TTL_SECONDS, + readAdminMfaProof, + signAdminMfaProof, +} from "./admin-mfa-proof"; + +const codeBody = Schema.Struct({ code: Schema.String.check(Schema.isPattern(/^\d{6}$/)) }); +const parseCodeBody = Schema.decodeUnknownOption(Schema.fromJsonString(codeBody)); +class RateLimitError extends Data.TaggedError("AdminMfaRateLimitError")<{ + readonly cause: unknown; +}> {} +class CodeBodyTooLarge extends Data.TaggedError("CodeBodyTooLarge") {} +const cookieOptions = { + path: "/", + httpOnly: true, + secure: true, + sameSite: "strict" as const, + maxAge: Duration.seconds(ADMIN_MFA_TTL_SECONDS), +}; +const json = (body: unknown, status = 200) => + HttpServerResponse.jsonUnsafe(body, { status, headers: { "cache-control": "no-store" } }); + +const handler = (action: "status" | "start" | "verify" | "cancel") => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const webRequest = yield* HttpServerRequest.toWeb(request); + if (action !== "status" && request.headers.origin !== new URL(webRequest.url).origin) { + return json({ message: "This request must come from Executor." }, 403); + } + const workos = yield* WorkOSClient; + const session = yield* workos.authenticateRequest(webRequest); + if (!session) return json({ message: "Sign in to continue." }, 401); + const response = yield* Effect.gen(function* () { + const selector = request.headers[ORG_SELECTOR_HEADER]; + const org = selector ? yield* authorizeOrganizationSelector(session.userId, selector) : null; + if (!org) return json({ message: "Select an organization to continue." }, 403); + if (action === "status") { + return json( + org.memberRole !== "admin" + ? { state: "member" } + : session.adminVerified + ? { state: "verified", expiresAt: session.adminVerificationExpiresAt } + : { state: "required" }, + ); + } + if (org.memberRole !== "admin") return json({ message: "Admin access is required." }, 403); + + if (action === "cancel") { + return HttpServerResponse.setCookieUnsafe( + json({ canceled: true }), + ADMIN_MFA_CHALLENGE_COOKIE, + "", + { + ...cookieOptions, + maxAge: Duration.seconds(0), + }, + ); + } + + // Applies across new challenges too, so starting over cannot reset the attempt budget. + const rateLimit = env.ADMIN_MFA_RATE_LIMITER; + if (!rateLimit) return json({ message: "Verification is temporarily unavailable." }, 503); + const allowed = yield* Effect.tryPromise({ + try: () => rateLimit.limit({ key: session.userId }), + catch: (cause) => new RateLimitError({ cause }), + }); + if (!allowed.success) return json({ message: "Wait a minute, then try again." }, 429); + + const now = yield* Clock.currentTimeMillis; + const identity = { userId: session.userId, sessionId: session.sessionId }; + const factors = yield* workos.listMfaFactors(session.userId); + if (action === "start") { + const existing = factors[0]; + const started = existing + ? { + kind: "challenge" as const, + factor: existing, + challenge: yield* workos.challengeMfa(existing.id), + } + : yield* workos.enrollMfa(session.userId, session.email).pipe( + Effect.map((result) => ({ + kind: "enroll" as const, + factor: result.authenticationFactor, + challenge: result.authenticationChallenge, + })), + ); + const token = yield* signAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + identity, + "challenge", + { + mode: started.kind, + factorId: started.factor.id, + challengeId: started.challenge.id, + exp: Math.floor(now / 1000) + 5 * 60, + }, + now, + ); + const response = + started.kind === "enroll" + ? json({ + kind: "enroll", + secret: started.factor.totp.secret, + qrCode: started.factor.totp.qrCode, + }) + : json({ kind: "challenge" }); + return HttpServerResponse.setCookieUnsafe(response, ADMIN_MFA_CHALLENGE_COOKIE, token, { + ...cookieOptions, + maxAge: Duration.minutes(5), + }); + } + + const pending = yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + identity, + "challenge", + request.cookies[ADMIN_MFA_CHALLENGE_COOKIE], + now, + ); + // AuthKit lists only verified factors. An enrollment may proceed only while + // none is active; a stale setup must not add a factor after another setup won. + if ( + !pending || + (pending.mode === "enroll" + ? factors.length !== 0 + : !factors.some( + (factor) => factor.id === pending.factorId && factor.userId === session.userId, + )) + ) { + return json({ message: "Start verification again." }, 400); + } + const text = yield* request.stream.pipe( + Stream.runFoldEffect( + () => new Uint8Array(0), + (body, chunk) => { + if (body.length + chunk.length > 256) return Effect.fail(new CodeBodyTooLarge()); + const next = new Uint8Array(body.length + chunk.length); + next.set(body); + next.set(chunk, body.length); + return Effect.succeed(next); + }, + ), + Effect.map((body) => new TextDecoder().decode(body)), + Effect.catch(() => Effect.succeed("")), + ); + const body = Option.getOrNull(parseCodeBody(text)); + if (!body) return json({ message: "Enter the six-digit code." }, 400); + const result = yield* workos + .verifyMfa(pending.challengeId, body.code) + .pipe( + Effect.catchTag("WorkOSError", (error) => + error.status === 400 || error.status === 422 + ? Effect.succeed(null) + : Effect.fail(error), + ), + ); + if ( + !result || + !result.valid || + result.challenge.authenticationFactorId !== pending.factorId + ) { + return json( + { message: "That code did not work. Try the current code from your authenticator." }, + 400, + ); + } + const active = yield* workos.listMfaFactors(session.userId); + if ( + !active.some((factor) => factor.id === pending.factorId && factor.userId === session.userId) + ) { + return json({ message: "Start verification again." }, 400); + } + const token = yield* signAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + identity, + "verified", + { + ...pending, + exp: Math.floor(now / 1000) + ADMIN_MFA_TTL_SECONDS, + }, + now, + ); + return json({ verified: true }).pipe( + HttpServerResponse.setCookieUnsafe(ADMIN_MFA_COOKIE, token, cookieOptions), + HttpServerResponse.setCookieUnsafe(ADMIN_MFA_CHALLENGE_COOKIE, "", { + ...cookieOptions, + maxAge: Duration.seconds(0), + }), + ); + }).pipe( + Effect.catch(() => + Effect.succeed( + json({ message: "Verification is temporarily unavailable. Try again." }, 503), + ), + ), + ); + // Refresh tokens rotate once. Persist the new sealed session even when + // verification is refused, so the next request can still authenticate. + return session.refreshedSession + ? HttpServerResponse.setCookieUnsafe(response, "wos-session", session.refreshedSession, { + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: Duration.days(7), + }) + : response; + }).pipe( + Effect.catch(() => + Effect.succeed(json({ message: "Verification is temporarily unavailable. Try again." }, 503)), + ), + ); + +/** Session-bound TOTP verification routes. Mount with the normal request-scoped directory. */ +export const AdminMfaRoutes = Layer.mergeAll( + HttpRouter.add("GET", "/api/auth/admin-mfa", handler("status")), + HttpRouter.add("POST", "/api/auth/admin-mfa/start", handler("start")), + HttpRouter.add("POST", "/api/auth/admin-mfa/verify", handler("verify")), + HttpRouter.add("POST", "/api/auth/admin-mfa/cancel", handler("cancel")), +); diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 6453a0547f..ebb8c66186 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -15,6 +15,7 @@ import { import { MemberDirectory, NoOrganization } from "@executor-js/api/server"; // Pure constants/codec module (no React) — safe in the backend graph. import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint"; +import { ADMIN_MFA_COOKIE, ADMIN_MFA_CHALLENGE_COOKIE } from "./admin-mfa-proof"; import { SessionContext, SessionCookies } from "./middleware"; import { encodeLoginState, decodeLoginState } from "./login-state"; import { safeReturnTo } from "./return-to"; @@ -353,6 +354,9 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( "wos-session", ), AUTH_HINT_COOKIE, + ).pipe( + (response) => deleteResponseCookie(response, ADMIN_MFA_COOKIE), + (response) => deleteResponseCookie(response, ADMIN_MFA_CHALLENGE_COOKIE), ); }), ) @@ -562,7 +566,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // not an admin) and reported its role, so the gate is that one // value: a member removed or demoted moments ago is denied once the // write-through or the Events reconciler has landed the change. - if (session.memberRole !== "admin") { + if (session.memberRole !== "admin" || session.adminVerified !== true) { return yield* new OrganizationDeletionForbidden(); } diff --git a/apps/cloud/src/auth/middleware-live.ts b/apps/cloud/src/auth/middleware-live.ts index 16ac3a4ff6..1b30675669 100644 --- a/apps/cloud/src/auth/middleware-live.ts +++ b/apps/cloud/src/auth/middleware-live.ts @@ -4,7 +4,8 @@ // --------------------------------------------------------------------------- import { Effect, Layer, Redacted } from "effect"; -import { HttpServerResponse } from "effect/unstable/http"; +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { ADMIN_MFA_COOKIE } from "./admin-mfa-proof"; import { AuthContext, NoOrganization, Unauthorized } from "@executor-js/api/server"; @@ -27,7 +28,10 @@ export const SessionAuthLive = Layer.effect( cookie: (httpEffect, { credential }) => Effect.gen(function* () { const result = yield* workos - .authenticateSealedSession(Redacted.value(credential)) + .authenticateSealedSession( + Redacted.value(credential), + (yield* HttpServerRequest.HttpServerRequest).cookies[ADMIN_MFA_COOKIE], + ) .pipe(Effect.orElseSucceed(() => null)); if (!result) { diff --git a/apps/cloud/src/auth/middleware.ts b/apps/cloud/src/auth/middleware.ts index cecd1a64f4..8f5ea36e13 100644 --- a/apps/cloud/src/auth/middleware.ts +++ b/apps/cloud/src/auth/middleware.ts @@ -38,6 +38,8 @@ export type Session = { readonly organizationId: string | null; readonly sealedSession: string; readonly refreshedSession: string | null; + /** True only while a verified second factor remains bound to this session. */ + readonly adminVerified?: boolean; }; export class SessionContext extends Context.Service()( @@ -69,6 +71,7 @@ export class SessionCookies extends Context.Service { organizationId: undefined, accessToken: "access", refreshToken: "refresh", + adminVerified: true, sealedSession: "sealed", }), listUserMemberships: (id) => { @@ -593,6 +594,7 @@ describe("session handlers read membership from the mirror", () => { ...options.workos, authenticateSealedSession: () => Effect.succeed({ + adminVerified: true, userId, email: `${userId}@placeholder.test`, organizationId: null, @@ -1099,6 +1101,7 @@ describe("account service writes through to the mirror", () => { name: null, avatarUrl: null, organizationId: null, + adminVerified: true, sealedSession: "sealed", refreshedSession: null, }); diff --git a/apps/cloud/src/auth/org-selector-auth.node.test.ts b/apps/cloud/src/auth/org-selector-auth.node.test.ts index f972a2e757..f652213c2c 100644 --- a/apps/cloud/src/auth/org-selector-auth.node.test.ts +++ b/apps/cloud/src/auth/org-selector-auth.node.test.ts @@ -99,6 +99,7 @@ const stubWorkOS = Layer.succeed( userId: MEMBER, email: "u@e2e.test", organizationId: SESSION_ORG, + adminVerified: true, }); } // Membership is read from the mirror, never from WorkOS: any WorkOS diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 7165a93042..1cfa76ea75 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -343,6 +343,15 @@ export const resolveSessionPrincipal = (request: Request) => } const org = yield* authorizeOrganizationSelector(session.userId, selector); if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); + // The shared executor API also exposes administrative workspace writes + // (integrations, policies and shared connections). Browser admins must + // verify before entering that plane, not only the account settings API. + if (org.memberRole === "admin" && session.adminVerified !== true) { + return yield* new NoOrganization({ + code: "admin_mfa_required", + message: "Verify with your authenticator to continue as a workspace admin.", + }); + } return { kind: "member", accountId: session.userId, diff --git a/apps/cloud/src/auth/workos.node.test.ts b/apps/cloud/src/auth/workos.node.test.ts index ca81b7717a..e9bf44b16c 100644 --- a/apps/cloud/src/auth/workos.node.test.ts +++ b/apps/cloud/src/auth/workos.node.test.ts @@ -211,6 +211,8 @@ describe("authenticateSealedSession", () => { organizationId: "org_test", sessionId: "session_valid", refreshedSession: undefined, + adminVerified: false, + adminVerificationExpiresAt: null, }); expect(stub.requests()).toEqual([ { method: "GET", path: `/sso/jwks/${CLIENT_ID}`, body: null }, diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 918a7e8556..189f01704a 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -16,6 +16,7 @@ import { decodeJwt, jwtVerify } from "jose"; import { workosAccessTokenOptions } from "./access-token-options"; import { JWKSInvalid, JWKSNoMatchingKey, JWKSTimeout } from "jose/errors"; import { parseCookie } from "./cookies"; +import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "./admin-mfa-proof"; import { createCachedRemoteJWKSet, type CachedRemoteJWKSet } from "./jwks-cache"; import { ServiceAdapterError, @@ -431,7 +432,18 @@ const make = Effect.gen(function* () { tryPromiseService(() => fn(workos)), ); - const authenticateSealedSession = (sessionData: string) => + // MFA SDK errors can contain response details. Keep only the status before + // logging, so enrollment secrets and submitted codes cannot enter a cause. + const useMfa = (op: string, fn: (wos: WorkOS) => Promise) => + tryPromiseService(() => fn(workos)).pipe( + Effect.mapError(workosErrorFromFailure), + Effect.tapError((error) => + Effect.logWarning(`workos.${op} failed`, { status: error.status }), + ), + Effect.withSpan(`workos.${op}`), + ); + + const authenticateSealedSession = (sessionData: string, adminProof?: string) => Effect.gen(function* () { if (!sessionData) return null; @@ -447,6 +459,16 @@ const make = Effect.gen(function* () { ); if (isLocalSessionValid(local)) { + const proof = yield* readAdminMfaProof( + cookiePassword, + { + userId: local.session.user.id, + sessionId: local.sessionId, + }, + "verified", + adminProof, + Date.now(), + ); return { userId: local.session.user.id, email: local.session.user.email, @@ -455,6 +477,8 @@ const make = Effect.gen(function* () { avatarUrl: local.session.user.profilePictureUrl, organizationId: local.organizationId, sessionId: local.sessionId, + adminVerified: proof !== null, + adminVerificationExpiresAt: proof?.exp ?? null, refreshedSession: undefined as string | undefined, }; } @@ -469,6 +493,17 @@ const make = Effect.gen(function* () { if (!refreshed.authenticated || !("sealedSession" in refreshed) || !refreshed.sealedSession) return null; + const proof = yield* readAdminMfaProof( + cookiePassword, + { + userId: refreshed.user.id, + sessionId: refreshed.sessionId, + }, + "verified", + adminProof, + Date.now(), + ); + return { userId: refreshed.user.id, email: refreshed.user.email, @@ -477,11 +512,38 @@ const make = Effect.gen(function* () { avatarUrl: refreshed.user.profilePictureUrl, organizationId: refreshed.organizationId, sessionId: refreshed.sessionId, + adminVerified: proof !== null, + adminVerificationExpiresAt: proof?.exp ?? null, refreshedSession: refreshed.sealedSession, }; }); return { + /** List factors belonging to this user; callers cannot supply another user's factor. */ + listMfaFactors: (userId: string) => + useMfa("userManagement.listAuthFactors", (wos) => + wos.userManagement + .listAuthFactors({ userId, limit: 100 }) + .then((page) => page.autoPagination()), + ), + /** Begin AuthKit's user-bound TOTP enrollment. The secret is returned only to that user. */ + enrollMfa: (userId: string, email: string) => + useMfa("userManagement.enrollAuthFactor", (wos) => + wos.userManagement.enrollAuthFactor({ + userId, + type: "totp", + totpIssuer: "Executor", + totpUser: email, + }), + ), + /** Challenge an already resolved factor. */ + challengeMfa: (authenticationFactorId: string) => + useMfa("mfa.challengeFactor", (wos) => wos.mfa.challengeFactor({ authenticationFactorId })), + /** Verify a TOTP code with WorkOS; never log the code or factor secret. */ + verifyMfa: (authenticationChallengeId: string, code: string) => + useMfa("mfa.verifyChallenge", (wos) => + wos.mfa.verifyChallenge({ authenticationChallengeId, code }), + ), getAuthorizationUrl: (redirectUri: string, state?: string) => workos.userManagement.getAuthorizationUrl({ provider: "authkit", @@ -595,7 +657,10 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const sessionData = parseCookie(request.headers.get("cookie"), COOKIE_NAME); if (!sessionData) return null; - return yield* authenticateSealedSession(sessionData); + return yield* authenticateSealedSession( + sessionData, + parseCookie(request.headers.get("cookie"), ADMIN_MFA_COOKIE) ?? undefined, + ); }), /** diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 715991f394..017570cf1a 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -5,6 +5,12 @@ declare global { namespace Cloudflare { interface Env { + /** TOTP enrollment and verification attempts; absence refuses verification. */ + ADMIN_MFA_RATE_LIMITER?: { + readonly limit: (options: { + readonly key: string; + }) => Promise<{ readonly success: boolean }>; + }; // Observability // Worker version metadata binding (wrangler.jsonc `version_metadata`). // Optional so test workers and local setups without the binding still diff --git a/apps/cloud/src/extensions/billing/route.ts b/apps/cloud/src/extensions/billing/route.ts index 78a32323fd..ece3efbe85 100644 --- a/apps/cloud/src/extensions/billing/route.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -64,6 +64,13 @@ const handler = Effect.gen(function* () { }); } const org = yield* resolveBillingOrganization(webRequest, session); + if (org.memberRole === "admin" && session.adminVerified !== true) { + return yield* new HttpResponseError({ + status: 403, + code: "admin_mfa_required", + message: "Verify with your authenticator to continue as a workspace admin.", + }); + } const url = new URL(webRequest.url); const body = diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index f1c4389fe7..70674d57a7 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -39,6 +39,7 @@ import { } from "../auth/handlers"; import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; import { SessionAuthLive } from "../auth/middleware-live"; +import { AdminMfaRoutes } from "../auth/admin-mfa-routes"; import { runWorkOsEventsSync } from "../auth/workos-events-runner"; import { makeWorkOsWebhookRoute } from "../auth/workos-webhook"; import { makeCloudAdminUsersRoutes } from "../admin/admin-users-api"; @@ -131,6 +132,7 @@ export const makeCloudExtensionRoutes = ( }); return [ + AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(rsLive).layer)), SessionRoutes, OrgRoutes, AdminUsersRoutes, diff --git a/apps/cloud/src/org/auth-middleware.ts b/apps/cloud/src/org/auth-middleware.ts index 9c61236f3b..4be83a5dcb 100644 --- a/apps/cloud/src/org/auth-middleware.ts +++ b/apps/cloud/src/org/auth-middleware.ts @@ -12,6 +12,7 @@ import { sessionFromSealed } from "../auth/middleware"; import { WorkOsMirror } from "../auth/workos-mirror"; import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; import { WorkOSClient } from "../auth/workos"; +import { ADMIN_MFA_COOKIE } from "../auth/admin-mfa-proof"; import { DbService } from "../db/db"; const unauthorized = () => @@ -41,7 +42,7 @@ const noOrganization = () => */ export class OrgMemberRole extends Context.Service< OrgMemberRole, - { readonly memberRole: "admin" | "member" } + { readonly memberRole: "admin" | "member"; readonly adminVerified?: boolean } >()("@executor-js/cloud/OrgMemberRole") {} const OrgAuthMiddleware = HttpRouter.middleware<{ @@ -55,7 +56,7 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ const request = yield* HttpServerRequest.HttpServerRequest; const cookieValue = request.cookies["wos-session"] ?? ""; const result = yield* workos - .authenticateSealedSession(cookieValue) + .authenticateSealedSession(cookieValue, request.cookies[ADMIN_MFA_COOKIE]) .pipe(Effect.orElseSucceed(() => null)); if (!result) return unauthorized(); @@ -84,7 +85,10 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ return yield* Effect.provideContext( httpEffect, Context.make(AuthContext, auth).pipe( - Context.add(OrgMemberRole, { memberRole: org.memberRole }), + Context.add(OrgMemberRole, { + memberRole: org.memberRole, + adminVerified: result.adminVerified, + }), ), ); }).pipe(Effect.provideContext(captured)); diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index 1cae2aa9a2..4a564d7a32 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -74,7 +74,7 @@ const provide = ( ): Layer.Layer => Layer.mergeAll( Layer.succeed(AuthContext)(adminAuth), - Layer.succeed(OrgMemberRole)({ memberRole }), + Layer.succeed(OrgMemberRole)({ memberRole, adminVerified: true }), stubWorkOS(workosOverrides), ); @@ -84,6 +84,12 @@ describe("Org domain handlers", () => { requireAdmin.pipe(Effect.provide(provide("admin"))), ); + it.effect("rejects an admin without a verified second factor", () => + Effect.gen(function* () { + expect(yield* Effect.flip(requireAdmin)).toBeInstanceOf(Forbidden); + }).pipe(Effect.provideService(OrgMemberRole, { memberRole: "admin", adminVerified: false })), + ); + it.effect("rejects a non-admin caller with Forbidden", () => Effect.gen(function* () { const error = yield* Effect.flip(requireAdmin); @@ -239,6 +245,7 @@ const workosForCaller = (deleted: string[]) => authenticateSealedSession: () => Effect.succeed({ userId: CALLER, + adminVerified: true, email: "caller@placeholder.test", organizationId: ORG, }), diff --git a/apps/cloud/src/org/handlers.ts b/apps/cloud/src/org/handlers.ts index 64c9f329b2..96e74c651e 100644 --- a/apps/cloud/src/org/handlers.ts +++ b/apps/cloud/src/org/handlers.ts @@ -29,8 +29,8 @@ import { OrgMemberRole } from "./auth-middleware"; * with `Forbidden` for a member. Exported for its test only. */ export const requireAdmin = Effect.gen(function* () { - const { memberRole } = yield* OrgMemberRole; - if (memberRole !== "admin") return yield* new Forbidden(); + const { memberRole, adminVerified } = yield* OrgMemberRole; + if (memberRole !== "admin" || adminVerified !== true) return yield* new Forbidden(); }); // Target-ownership check — independent of caller privilege. `requireAdmin` diff --git a/apps/cloud/src/web/components/admin-verification.tsx b/apps/cloud/src/web/components/admin-verification.tsx new file mode 100644 index 0000000000..acdb680fc3 --- /dev/null +++ b/apps/cloud/src/web/components/admin-verification.tsx @@ -0,0 +1,252 @@ +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { Cause, Data, Effect, Exit, Option, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; +import { getExecutorOrganizationHeaders } from "@executor-js/react/api/server-connection"; +import { useAuth } from "../auth"; + +const Status = Schema.Union([ + Schema.Struct({ state: Schema.Literal("member") }), + Schema.Struct({ state: Schema.Literal("required") }), + Schema.Struct({ state: Schema.Literal("verified"), expiresAt: Schema.Number }), +]); +const Challenge = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("challenge") }), + Schema.Struct({ + kind: Schema.Literal("enroll"), + secret: Schema.String, + qrCode: Schema.String.check(Schema.isPattern(/^data:image\/png;base64,/)), + }), +]); +const Message = Schema.Struct({ message: Schema.String }); +const Verified = Schema.Struct({ verified: Schema.Literal(true) }); +const Canceled = Schema.Struct({ canceled: Schema.Literal(true) }); +const unavailable = "Verification is unavailable. Try again."; + +class VerificationError extends Data.TaggedError("VerificationError")<{ + readonly message: string; +}> {} +const decodeStatus = Schema.decodeUnknownOption(Status); +const decodeChallenge = Schema.decodeUnknownOption(Challenge); +const decodeMessage = Schema.decodeUnknownOption(Message); +const decodeVerified = Schema.decodeUnknownOption(Verified); +const decodeCanceled = Schema.decodeUnknownOption(Canceled); + +function request( + path: string, + decode: (value: unknown) => Option.Option, + body?: Readonly>, +): Effect.Effect { + return Effect.gen(function* () { + const { response, raw } = yield* Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const url = `/api/auth/admin-mfa${path}`; + const base = body === undefined ? HttpClientRequest.get(url) : HttpClientRequest.post(url); + const payload = body === undefined ? base : yield* HttpClientRequest.bodyJson(base, body); + const response = yield* client.execute( + HttpClientRequest.setHeaders(payload, getExecutorOrganizationHeaders()), + ); + const raw = yield* response.json; + return { response, raw }; + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.mapError(() => new VerificationError({ message: unavailable })), + ); + if (response.status < 200 || response.status >= 300) { + const message = Option.getOrNull(decodeMessage(raw)); + return yield* new VerificationError({ message: message?.message ?? unavailable }); + } + const parsed = decode(raw); + if (Option.isNone(parsed)) return yield* new VerificationError({ message: unavailable }); + return parsed.value; + }); +} + +/** Require a second factor before mounting cloud admin controls. Members retain their existing view. */ +export function AdminVerification({ children }: { readonly children: ReactNode }) { + const auth = useAuth(); + const scope = auth.status === "authenticated" ? auth.organization?.id : undefined; + if (!scope) return null; + return {children}; +} + +function VerificationFlow({ children }: { readonly children: ReactNode }) { + const [status, setStatus] = useState(null); + const [challenge, setChallenge] = useState(null); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const controller = useRef(null); + const codeId = useId(); + + const run = async ( + effect: Effect.Effect, + signal: AbortSignal, + onSuccess: (value: A) => void, + ) => { + const exit = await Effect.runPromiseExit(effect, { signal }); + if (signal.aborted) return; + if (Exit.isSuccess(exit)) onSuccess(exit.value); + else setError(Option.getOrNull(Cause.findErrorOption(exit.cause))?.message ?? unavailable); + }; + + useEffect(() => { + const owner = new AbortController(); + controller.current = owner; + const load = () => run(request("", decodeStatus), owner.signal, setStatus); + void load(); + window.addEventListener("focus", load); + return () => { + owner.abort(); + window.removeEventListener("focus", load); + }; + }, []); + + useEffect(() => { + if (status?.state !== "verified") return; + const timeout = window.setTimeout( + () => { + setStatus({ state: "required" }); + setChallenge(null); + setCode(""); + }, + Math.max(0, status.expiresAt * 1000 - Date.now()), + ); + return () => window.clearTimeout(timeout); + }, [status]); + + const act = async (action: "start" | "verify" | "cancel" | "retry") => { + const signal = controller.current?.signal; + if (!signal || signal.aborted || busy) return; + setBusy(true); + setError(null); + if (action === "start") { + await run(request("/start", decodeChallenge, {}), signal, (next) => { + setChallenge(next); + setCode(""); + }); + } else if (action === "verify") { + await run(request("/verify", decodeVerified, { code }), signal, () => { + setChallenge(null); + setCode(""); + // Reload clears cached admin requests and the enrollment secret while + // retaining the current organization's URL. + window.location.reload(); + }); + } else if (action === "cancel") { + await run(request("/cancel", decodeCanceled, {}), signal, () => { + setChallenge(null); + setCode(""); + }); + } else { + await run(request("", decodeStatus), signal, setStatus); + } + if (!signal.aborted) setBusy(false); + }; + + if (status?.state === "member" || status?.state === "verified") return children; + + return ( +
+

Verify to use admin settings

+

+ Use an authenticator app to confirm it’s you. Verification lasts 15 minutes. +

+ {error && ( +

+ {error} +

+ )} + {!status ? ( +
+ {error ? ( + + ) : ( +

Checking access…

+ )} +
+ ) : challenge ? ( +
{ + event.preventDefault(); + void act("verify"); + }} + > + {challenge.kind === "enroll" && ( +
+

Scan this code with your authenticator app.

+ Authenticator setup QR code +
+ Enter a setup key instead +

{challenge.secret}

+
+
+ )} +
+ + setCode(event.target.value.replace(/\D/g, ""))} + /> +
+
+ + + +
+ {challenge.kind === "challenge" && ( +

+ Lost your authenticator?{" "} + + Contact support + + . +

+ )} +
+ ) : ( + + )} +
+ ); +} diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index 99a60178df..70dd54c766 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { Outlet } from "@tanstack/react-router"; import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav"; @@ -6,6 +7,7 @@ import { trackEvent } from "@executor-js/react/api/analytics"; import { AUTH_PATHS } from "../auth/api"; import { OrgMenuSlot } from "./components/org-menu-slot"; import { SupportSlot } from "./components/support-slot"; +import { AdminVerification } from "./components/admin-verification"; // --------------------------------------------------------------------------- // Cloud shell — the SHARED multiplayer shell, identical to self-host, with @@ -53,7 +55,13 @@ export function Shell(props: { readonly content?: React.ReactNode }) { navItems={items} orgMenuSlot={} supportSlot={} - content={props.content} + content={ + props.content ?? ( + + + + ) + } /> ); } diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 6d92064589..ac07846ebc 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -27,7 +27,15 @@ }, "observability": { "enabled": true, + "redact_query_string": true, }, + "ratelimits": [ + { + "name": "ADMIN_MFA_RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 5, "period": 60 }, + }, + ], // Script-level logpush feeds the account's workers_trace_events Logpush job // (invocation logs, outcomes like exceededMemory, console output) into // Axiom. Pinned here because the setting lives on the script: a deploy that diff --git a/bun.lock b/bun.lock index 52aac3adf6..4e412e4e5d 100644 --- a/bun.lock +++ b/bun.lock @@ -356,7 +356,7 @@ "version": "0.0.48", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.2", + "@executor-js/emulate": "0.14.3-mfa.0", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -381,6 +381,7 @@ "@vitejs/plugin-react": "catalog:", "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", + "otpauth": "9.5.2", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", @@ -1811,7 +1812,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.14.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-rUzfQFq1dO3qwzW83jL7kEikLLPXTjqLTSU9qpVdbYyqMF/Ef8YgwH+hw0tbqNEkuGFwuZKkMkDUPPfkidMamg=="], + "@executor-js/emulate": ["@executor-js/emulate@0.14.3-mfa.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "jose": "^6", "openid-client": "^6.8.4", "otpauth": "9.5.2", "picocolors": "^1.1.1", "qrcode": "1.5.4", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-XdXLM+Q5lWtfkgAqa95atZmpKAjel9A29ulJeDCgMlKwPerQJ1d8yWsAn5b3iq68Bq5HGwtjZt7ebBuwZP3dBw=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], @@ -2221,7 +2222,7 @@ "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], - "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -3527,6 +3528,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], @@ -3771,6 +3774,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -3827,6 +3832,8 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], @@ -4899,6 +4906,8 @@ "ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="], + "otpauth": ["otpauth@9.5.2", "", { "dependencies": { "@noble/hashes": "2.4.0" } }, "sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "oxc-parser": ["oxc-parser@0.121.0", "", { "dependencies": { "@oxc-project/types": "^0.121.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.121.0", "@oxc-parser/binding-android-arm64": "0.121.0", "@oxc-parser/binding-darwin-arm64": "0.121.0", "@oxc-parser/binding-darwin-x64": "0.121.0", "@oxc-parser/binding-freebsd-x64": "0.121.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.121.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.121.0", "@oxc-parser/binding-linux-arm64-gnu": "0.121.0", "@oxc-parser/binding-linux-arm64-musl": "0.121.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-musl": "0.121.0", "@oxc-parser/binding-linux-s390x-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-musl": "0.121.0", "@oxc-parser/binding-openharmony-arm64": "0.121.0", "@oxc-parser/binding-wasm32-wasi": "0.121.0", "@oxc-parser/binding-win32-arm64-msvc": "0.121.0", "@oxc-parser/binding-win32-ia32-msvc": "0.121.0", "@oxc-parser/binding-win32-x64-msvc": "0.121.0" } }, "sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg=="], @@ -5019,7 +5028,7 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], - "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -5101,6 +5110,8 @@ "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -5279,6 +5290,8 @@ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], @@ -5365,6 +5378,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -5791,6 +5806,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -5907,6 +5924,8 @@ "@better-auth/core/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@better-auth/utils/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -6001,6 +6020,8 @@ "@executor-js/emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "@executor-js/emulate/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "@executor-js/example-all-plugins/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], @@ -6009,6 +6030,8 @@ "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "@executor-js/host-selfhost/@executor-js/emulate": ["@executor-js/emulate@0.14.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-rUzfQFq1dO3qwzW83jL7kEikLLPXTjqLTSU9qpVdbYyqMF/Ef8YgwH+hw0tbqNEkuGFwuZKkMkDUPPfkidMamg=="], + "@executor-js/mcporter/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@executor-js/mcporter/rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], @@ -6043,6 +6066,8 @@ "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@jimp/js-png/pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -6259,6 +6284,8 @@ "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], @@ -6483,6 +6510,8 @@ "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], + "app-builder-lib/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], "app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -6519,6 +6548,8 @@ "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "better-auth/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "better-auth/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -6791,6 +6822,8 @@ "protobufjs/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -7171,6 +7204,10 @@ "@executor-js/e2e/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@executor-js/host-selfhost/@executor-js/emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "@executor-js/host-selfhost/@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "@executor-js/mcporter/rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], "@executor-js/mcporter/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], @@ -7823,6 +7860,14 @@ "protobufjs/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "request/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -8079,6 +8124,10 @@ "ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "qrcode/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "superagent/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -8119,6 +8168,8 @@ "googleapis-common/google-auth-library/gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "temp/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], diff --git a/e2e/cloud/admin-mfa-api.test.ts b/e2e/cloud/admin-mfa-api.test.ts new file mode 100644 index 0000000000..e408d42f54 --- /dev/null +++ b/e2e/cloud/admin-mfa-api.test.ts @@ -0,0 +1,121 @@ +import { expect } from "@effect/vitest"; +import { Effect, Option, Schema } from "effect"; +import { TOTP } from "otpauth"; +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; +import { responseCookies } from "./support/admin-mfa"; + +const decodeSetup = Schema.decodeUnknownOption( + Schema.Struct({ kind: Schema.Literal("enroll"), secret: Schema.String }), +); + +scenario( + "Admin MFA API · requires same-origin requests and binds verification to the session", + {}, + Effect.gen(function* () { + const target = yield* Target; + const identity = yield* target.newIdentity({ adminMfa: false }); + const other = yield* target.newIdentity({ adminMfa: false }); + yield* Effect.promise(async () => { + const original = identity.headers?.cookie ?? ""; + const headers = { ...identity.headers, "content-type": "application/json" }; + const unverifiedWorkspace = await fetch(new URL("/api/policies", target.baseUrl), { + headers, + }); + expect(unverifiedWorkspace.status).toBe(403); + expect(await unverifiedWorkspace.json()).toMatchObject({ code: "admin_mfa_required" }); + const key = await fetch(new URL("/api/account/api-keys", target.baseUrl), { + method: "POST", + headers: { ...headers, origin: new URL(target.baseUrl).origin }, + body: JSON.stringify({ name: "unverified-admin" }), + }); + expect(key.status).toBe(403); + const billing = await fetch(new URL("/api/billing/customer", target.baseUrl), { headers }); + expect(billing.status).toBe(403); + const post = (action: string, cookie: string, code?: string) => + fetch(new URL(`/api/auth/admin-mfa/${action}`, target.baseUrl), { + method: "POST", + headers: { ...headers, origin: new URL(target.baseUrl).origin, cookie }, + body: JSON.stringify(code === undefined ? {} : { code }), + }); + const noOrigin = await fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { + method: "POST", + headers, + body: "{}", + }); + expect(noOrigin.status).toBe(403); + const crossOrigin = await fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { + method: "POST", + headers: { ...headers, origin: "https://other.example" }, + body: "{}", + }); + expect(crossOrigin.status).toBe(403); + const started = await post("start", original); + expect(started.status).toBe(200); + const setup = Option.getOrNull(decodeSetup(await started.json())); + if (!setup) throw new Error("Expected enrollment setup"); + const pending = responseCookies(original, started); + const challenge = pending + .split("; ") + .find((pair) => pair.startsWith("__Host-executor-admin-challenge=")); + if (!challenge) throw new Error("Expected pending challenge cookie"); + const crossUser = await fetch(new URL("/api/auth/admin-mfa/verify", target.baseUrl), { + method: "POST", + headers: { + ...other.headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + cookie: `${other.headers?.cookie ?? ""}; ${challenge}`, + }, + body: JSON.stringify({ code: new TOTP({ secret: setup.secret }).generate() }), + }); + expect(crossUser.status).toBe(400); + const verified = await post("verify", pending, new TOTP({ secret: setup.secret }).generate()); + expect(verified.status).toBe(200); + const verifiedCookies = responseCookies(pending, verified); + const status = await fetch(new URL("/api/auth/admin-mfa", target.baseUrl), { + headers: { ...headers, cookie: verifiedCookies }, + }); + expect(await status.json()).toMatchObject({ state: "verified" }); + expect( + (await post("verify", pending, new TOTP({ secret: setup.secret }).generate())).status, + ).toBe(400); + + // A later verification uses the existing factor and never returns its secret. + const repeat = await post("start", original); + expect(await repeat.json()).toEqual({ kind: "challenge" }); + const repeated = await post( + "verify", + responseCookies(original, repeat), + new TOTP({ secret: setup.secret }).generate(), + ); + expect(repeated.status).toBe(200); + }); + }), +); + +scenario( + "Admin MFA API · restarting enrollment cannot reset the rate limit", + {}, + Effect.gen(function* () { + const target = yield* Target; + const identity = yield* target.newIdentity({ adminMfa: false }); + yield* Effect.promise(async () => { + const statuses: number[] = []; + for (let attempt = 0; attempt < 6; attempt++) { + const response = await fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { + method: "POST", + headers: { + ...identity.headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + }, + body: "{}", + }); + statuses.push(response.status); + await response.text(); + } + expect(statuses).toEqual([200, 200, 200, 200, 200, 429]); + }); + }), +); diff --git a/e2e/cloud/admin-mfa.test.ts b/e2e/cloud/admin-mfa.test.ts new file mode 100644 index 0000000000..57470c9e07 --- /dev/null +++ b/e2e/cloud/admin-mfa.test.ts @@ -0,0 +1,99 @@ +import { expect } from "@effect/vitest"; +import { Effect, Option, Schema } from "effect"; +import { TOTP } from "otpauth"; +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { joinOrg, orgSelectorOf } from "./support/session"; + +const Setup = Schema.Struct({ kind: Schema.Literal("enroll"), secret: Schema.String }); +const decodeSetup = Schema.decodeUnknownOption(Setup); +const proofName = "__Host-executor-admin-mfa"; + +scenario( + "Admin MFA · enroll, cancel, retry, and verify before opening admin settings", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity({ adminMfa: false }); + const path = `/${orgSelectorOf(identity)}/org`; + yield* browser.session(identity, async ({ page, step }) => { + await step("Open organization settings without a second factor", async () => { + await page.goto(path); + await page.getByRole("heading", { name: "Verify to use admin settings" }).waitFor(); + const denied = await page.request.get("/api/admin/users", { headers: identity.headers }); + expect(denied.status()).toBe(403); + const keyDenied = await page.request.get("/api/account/org-api-keys", { + headers: identity.headers, + }); + expect(keyDenied.status()).toBe(403); + }); + await step("Start setup and cancel it", async () => { + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByAltText("Authenticator setup QR code").waitFor(); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).waitFor(); + }); + let secret = ""; + await step("Start setup again and enter an incorrect code", async () => { + const pending = page.waitForResponse((response) => + response.url().endsWith("/api/auth/admin-mfa/start"), + ); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + const setup = Option.getOrNull(decodeSetup(await (await pending).json())); + expect(setup).not.toBeNull(); + if (!setup) throw new Error("MFA setup did not return a secret"); + secret = setup.secret; + const oldCode = new TOTP({ secret }).generate({ timestamp: Date.now() - 600_000 }); + await page.getByLabel("Six-digit code").fill(oldCode); + await page.getByRole("button", { name: "Verify", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: "That code did not work" }).waitFor(); + expect((await page.context().cookies()).some((cookie) => cookie.name === proofName)).toBe( + false, + ); + }); + await step("Enter the current code and open admin settings", async () => { + await page.getByLabel("Six-digit code").fill(new TOTP({ secret }).generate()); + await page.getByRole("button", { name: "Verify", exact: true }).click(); + await page.getByRole("button", { name: "Add domain", exact: true }).waitFor(); + expect(new URL(page.url()).pathname).toBe(path); + const proof = (await page.context().cookies()).find((cookie) => cookie.name === proofName); + expect(proof).toMatchObject({ httpOnly: true, secure: true, sameSite: "Strict" }); + const headers = { "x-executor-organization": orgSelectorOf(identity) }; + expect((await page.request.get("/api/admin/users", { headers })).status()).toBe(200); + }); + }); + }), +); + +scenario( + "Admin MFA · members keep their normal view and cannot enroll as an admin", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const admin = yield* target.newIdentity(); + const member = yield* joinOrg(target, admin, yield* target.newIdentity({ org: false })); + const response = yield* Effect.promise(() => + fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { + method: "POST", + headers: { + ...member.headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + }, + body: "{}", + }), + ); + expect(response.status).toBe(403); + yield* browser.session(member, async ({ page, step }) => { + await step("Open organization settings as a member", async () => { + await page.goto(`/${orgSelectorOf(member)}/org`); + await page.getByRole("heading", { name: "Members", exact: true }).waitFor(); + expect( + await page.getByRole("heading", { name: "Verify to use admin settings" }).count(), + ).toBe(0); + }); + }); + }), +); diff --git a/e2e/cloud/auth-hint.test.ts b/e2e/cloud/auth-hint.test.ts index a04502c851..6d7b8bf634 100644 --- a/e2e/cloud/auth-hint.test.ts +++ b/e2e/cloud/auth-hint.test.ts @@ -112,6 +112,10 @@ scenario( const names = (await page.context().cookies()).map((cookie) => cookie.name); expect(names, "the hint never outlives the session").not.toContain(HINT_COOKIE); expect(names, "the session itself is gone too").not.toContain("wos-session"); + expect(names, "admin verification ends on logout").not.toContain("__Host-executor-admin-mfa"); + expect(names, "unfinished admin challenges are cleared").not.toContain( + "__Host-executor-admin-challenge", + ); }); }), ); diff --git a/e2e/cloud/support/admin-mfa.ts b/e2e/cloud/support/admin-mfa.ts new file mode 100644 index 0000000000..ba88e27690 --- /dev/null +++ b/e2e/cloud/support/admin-mfa.ts @@ -0,0 +1,77 @@ +import { Effect, Option, Schema } from "effect"; +import { TOTP } from "otpauth"; +import type { Identity } from "../../src/target"; + +const Setup = Schema.Struct({ kind: Schema.Literal("enroll"), secret: Schema.String }); +const decodeSetup = Schema.decodeUnknownOption(Setup); +const Verified = Schema.Struct({ verified: Schema.Literal(true) }); +const decodeVerified = Schema.decodeUnknownOption(Verified); + +/** Apply response cookie rotations and deletions to a test client's cookie header. */ +export const responseCookies = (current: string, response: Response): string => { + const cookies = new Map(browserCookies(current).map(({ name, value }) => [name, value])); + for (const header of response.headers.getSetCookie()) { + const pair = header.split(";")[0]; + if (!pair) throw new Error("Empty response cookie"); + const separator = pair.indexOf("="); + if (separator < 1) throw new Error("Invalid response cookie"); + const name = pair.slice(0, separator); + if (/;\s*max-age=0(?:;|$)/i.test(header)) cookies.delete(name); + else cookies.set(name, pair.slice(separator + 1)); + } + return [...cookies].map(([name, value]) => `${name}=${value}`).join("; "); +}; + +/** Read all cookie pairs, including admin verification, into browser fixtures. */ +export const browserCookies = (cookie: string): NonNullable => + cookie + .split(";") + .map((pair) => pair.trim()) + .filter(Boolean) + .map((pair) => { + const separator = pair.indexOf("="); + if (separator < 1) throw new Error("Invalid test cookie"); + const name = pair.slice(0, separator); + return { + name, + value: pair.slice(separator + 1), + ...(name.startsWith("__Host-") ? { secure: true } : {}), + }; + }); + +/** Enroll a fresh test admin through the real product and the installed WorkOS emulator. */ +export const verifyFreshAdmin = (baseUrl: string, identity: Identity): Effect.Effect => + Effect.promise(async () => { + const headers = { + ...identity.headers, + origin: new URL(baseUrl).origin, + "content-type": "application/json", + }; + const started = await fetch(new URL("/api/auth/admin-mfa/start", baseUrl), { + method: "POST", + headers, + body: "{}", + }); + if (!started.ok) throw new Error(`Admin enrollment failed (${started.status})`); + const setup = Option.getOrNull(decodeSetup(await started.json())); + if (!setup) throw new Error("Expected a fresh MFA enrollment"); + const pending = responseCookies(identity.headers?.cookie ?? "", started); + const verified = await fetch(new URL("/api/auth/admin-mfa/verify", baseUrl), { + method: "POST", + headers: { ...headers, cookie: pending }, + body: JSON.stringify({ code: new TOTP({ secret: setup.secret }).generate() }), + }); + if (!verified.ok || Option.isNone(decodeVerified(await verified.json()))) + throw new Error(`Admin verification failed (${verified.status})`); + const proof = verified.headers + .getSetCookie() + .find((cookie) => cookie.startsWith("__Host-executor-admin-mfa=")) + ?.split(";")[0]; + if (!proof) throw new Error("Admin verification set no proof cookie"); + const cookie = responseCookies(pending, verified); + return { + ...identity, + headers: { ...identity.headers, cookie }, + cookies: browserCookies(cookie), + }; + }); diff --git a/e2e/cloud/support/session.ts b/e2e/cloud/support/session.ts index cb8b0d6cf7..62ed040c36 100644 --- a/e2e/cloud/support/session.ts +++ b/e2e/cloud/support/session.ts @@ -17,6 +17,7 @@ // // `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module // is never collected as a suite. +import { browserCookies } from "./admin-mfa"; import { Effect } from "effect"; import type { Identity, Target as TargetShape } from "../../src/target"; @@ -53,7 +54,7 @@ export const forBrowser = (identity: Identity): Identity => { if (separator < 0) throw new Error("identity carries no session cookie"); return { ...identity, - cookies: [{ name: cookie.slice(0, separator), value: cookie.slice(separator + 1) }], + cookies: browserCookies(cookie), }; }; @@ -98,7 +99,15 @@ export const withRefreshedSession = ( .find((header) => header.startsWith("wos-session=")) ?.split(";")[0]; if (!refreshed) throw new Error("response did not refresh the session cookie"); - return { ...identity, headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector } }; + const cookies = browserCookies(cookieOf(identity)).filter( + (cookie) => cookie.name !== "wos-session", + ); + const cookie = [refreshed, ...cookies.map(({ name, value }) => `${name}=${value}`)].join("; "); + return { + ...identity, + headers: { cookie, [ORG_SELECTOR_HEADER]: orgSelector }, + cookies: browserCookies(cookie), + }; }; /** The org selector this identity's requests carry — the same header the web diff --git a/e2e/package.json b/e2e/package.json index d605fedf77..bf9adb9374 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.2", + "@executor-js/emulate": "0.14.3-mfa.0", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -48,6 +48,7 @@ "@vitejs/plugin-react": "catalog:", "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", + "otpauth": "9.5.2", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts index 6510f77d9a..de7275fcf8 100644 --- a/e2e/src/surfaces/browser.ts +++ b/e2e/src/surfaces/browser.ts @@ -202,10 +202,13 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface await installRecordingUrlBar(context); if (identity.cookies?.length) { await context.addCookies( - identity.cookies.map((cookie) => ({ - ...cookie, - url: target.baseUrl, - })), + identity.cookies.map((cookie) => { + const source = new URL(target.baseUrl); + // Chromium validates __Host- cookies against their source scheme, + // even on localhost where Secure cookies work over HTTP. + if (cookie.secure) source.protocol = "https:"; + return { ...cookie, url: source.href }; + }), ); } const page = await context.newPage(); diff --git a/e2e/src/target.ts b/e2e/src/target.ts index a410490537..6d6c809137 100644 --- a/e2e/src/target.ts +++ b/e2e/src/target.ts @@ -21,7 +21,11 @@ export interface Identity { /** Headers that authenticate API requests (e.g. a session cookie). */ readonly headers?: Record; /** Cookies to inject into a browser context for a logged-in page. */ - readonly cookies?: ReadonlyArray<{ readonly name: string; readonly value: string }>; + readonly cookies?: ReadonlyArray<{ + readonly name: string; + readonly value: string; + readonly secure?: boolean; + }>; /** Credentials for surfaces that sign in themselves (Better Auth, OAuth consent). */ readonly credentials?: { readonly email: string; readonly password: string }; } @@ -37,7 +41,10 @@ export interface Target { * `org: false` yields an identity with no active organization (for flows * that create one, like onboarding / billing limits). */ - readonly newIdentity: (options?: { readonly org?: boolean }) => Effect.Effect; + readonly newIdentity: (options?: { + readonly org?: boolean; + readonly adminMfa?: boolean; + }) => Effect.Effect; /** Headless OAuth consent for the MCP surface, when "mcp-oauth" is supported. */ readonly mcpConsent?: ( identity: Identity, diff --git a/e2e/targets/cloud.ts b/e2e/targets/cloud.ts index b5d360a2ad..b0f7828b32 100644 --- a/e2e/targets/cloud.ts +++ b/e2e/targets/cloud.ts @@ -10,6 +10,7 @@ import { Effect } from "effect"; import { connectEmulator } from "@executor-js/emulate"; +import { verifyFreshAdmin } from "../cloud/support/admin-mfa"; import { e2ePort } from "../src/ports"; import type { Identity, Target } from "../src/target"; @@ -71,7 +72,7 @@ export const cloudTarget = (): Target => ({ }); await workos.seed({ oauth: { default_access_token_ttl_seconds: seconds } }); }), - newIdentity: ({ org = true } = {}) => + newIdentity: ({ org = true, adminMfa = true } = {}) => Effect.promise(async (): Promise => { const label = `user-${randomUUID().slice(0, 8)}`; const email = `${label}@e2e.test`; @@ -96,7 +97,7 @@ export const cloudTarget = (): Target => ({ orgSlug = ((await response.json()) as { slug?: string }).slug ?? null; } const [name, value] = session.split(/=(.*)/s); - return { + const identity: Identity = { label: email, // The org selector header rides along exactly as the web client sends // it from the console URL's slug: org-scoped API reads fail closed @@ -108,6 +109,9 @@ export const cloudTarget = (): Target => ({ cookies: [{ name: name!, value: value! }], credentials: { email, password: "emulated" }, }; + return org && adminMfa + ? await Effect.runPromise(verifyFreshAdmin(CLOUD_BASE_URL, identity)) + : identity; }), // MCP OAuth against the emulator's authorization server: complete the // hosted flow headlessly as this identity. diff --git a/packages/core/api/src/account/api.ts b/packages/core/api/src/account/api.ts index 87ad648b9e..59dcc466ec 100644 --- a/packages/core/api/src/account/api.ts +++ b/packages/core/api/src/account/api.ts @@ -203,7 +203,7 @@ export const AccountApi = HttpApiGroup.make("account") HttpApiEndpoint.post("createApiKey", "/account/api-keys", { payload: CreateApiKeyBody, success: CreatedApiKeyResponse, - error: [AccountError, AccountUnauthorized, AccountNoOrganization], + error: [AccountError, AccountUnauthorized, AccountNoOrganization, AccountForbidden], }), ) .add( diff --git a/packages/core/api/src/account/service.ts b/packages/core/api/src/account/service.ts index 7978fb3ae2..5bbb8b4898 100644 --- a/packages/core/api/src/account/service.ts +++ b/packages/core/api/src/account/service.ts @@ -49,7 +49,11 @@ type OrgScoped = Authed; export interface AccountProviderShape { readonly me: (headers: AccountHeaders) => Authed; readonly listApiKeys: (headers: AccountHeaders) => OrgScoped; - readonly createApiKey: (headers: AccountHeaders, name: string) => OrgScoped; + /** Hosts may require additional verification before issuing a privileged user key. */ + readonly createApiKey: ( + headers: AccountHeaders, + name: string, + ) => OrgScoped; readonly revokeApiKey: (headers: AccountHeaders, apiKeyId: string) => OrgScoped; /** Org-owned keys: admin-gated, hence the `AccountForbidden` on both. */ readonly listOrgApiKeys: (headers: AccountHeaders) => OrgScoped; From 6e2be1fb87ae1f1b7071426c9f2230ec14789074 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:40:25 -0700 Subject: [PATCH 2/3] Resume OAuth connections after admin verification --- .../src/auth/oauth-admin-verification.test.ts | 37 ++++++++++++++++ .../src/auth/oauth-admin-verification.ts | 34 +++++++++++++++ apps/cloud/src/start.ts | 6 ++- e2e/cloud/connection-owner-isolation.test.ts | 21 ++------- e2e/cloud/oauth-callback-org-scope.test.ts | 2 + .../oauth-callback-unauthenticated.test.ts | 20 +++++++-- e2e/cloud/org-delete.test.ts | 2 + e2e/cloud/session-gate.test.ts | 5 ++- e2e/cloud/support/admin-mfa.ts | 43 ++++++++++++++++--- e2e/src/target.ts | 6 ++- e2e/targets/cloud.ts | 4 +- 11 files changed, 149 insertions(+), 31 deletions(-) create mode 100644 apps/cloud/src/auth/oauth-admin-verification.test.ts create mode 100644 apps/cloud/src/auth/oauth-admin-verification.ts diff --git a/apps/cloud/src/auth/oauth-admin-verification.test.ts b/apps/cloud/src/auth/oauth-admin-verification.test.ts new file mode 100644 index 0000000000..41e5377bbd --- /dev/null +++ b/apps/cloud/src/auth/oauth-admin-verification.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "@effect/vitest"; +import { encodeOAuthCallbackState } from "@executor-js/sdk/shared"; +import { oauthAdminVerificationResponse } from "./oauth-admin-verification"; + +describe("OAuth admin verification recovery", () => { + it("keeps provider credentials out of the recovery page and preserves session cookies", async () => { + const state = encodeOAuthCallbackState({ state: "private-state", orgSlug: "example-org" }); + const request = new Request( + `https://app.example/api/oauth/callback?code=private-code&state=${state}`, + ); + const denied = Response.json( + { code: "admin_mfa_required" }, + { + status: 403, + headers: { "set-cookie": "wos-session=rotated; Secure; HttpOnly" }, + }, + ); + const response = await oauthAdminVerificationResponse(request, denied); + expect(response.status).toBe(200); + expect(response.headers.get("set-cookie")).toContain("wos-session=rotated"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + expect(response.headers.get("content-security-policy")).toContain("default-src 'none'"); + const body = await response.text(); + expect(body).toContain('href="/example-org/org"'); + expect(body).toContain("Continue connection"); + expect(body).not.toContain("private-code"); + expect(body).not.toContain(state); + expect(body).not.toContain(" { + const request = new Request("https://app.example/api/oauth/callback?state=invalid"); + const denied = Response.json({ code: "no_organization" }, { status: 403 }); + expect(await oauthAdminVerificationResponse(request, denied)).toBe(denied); + }); +}); diff --git a/apps/cloud/src/auth/oauth-admin-verification.ts b/apps/cloud/src/auth/oauth-admin-verification.ts new file mode 100644 index 0000000000..7bf363eff3 --- /dev/null +++ b/apps/cloud/src/auth/oauth-admin-verification.ts @@ -0,0 +1,34 @@ +import { Option, Schema } from "effect"; +import { decodeOAuthCallbackState } from "@executor-js/sdk/shared"; + +const Required = Schema.Struct({ code: Schema.Literal("admin_mfa_required") }); +const decodeRequired = Schema.decodeUnknownOption(Required); + +/** Give an admin a verification path without consuming the pending OAuth callback. */ +export const oauthAdminVerificationResponse = async ( + request: Request, + response: Response, +): Promise => { + if (response.status !== 403 || request.method !== "GET") return response; + const url = new URL(request.url); + const state = decodeOAuthCallbackState(url.searchParams.get("state")); + if (!state || !response.headers.get("content-type")?.includes("application/json")) + return response; + const raw: unknown = await response.clone().json(); + if (Option.isNone(decodeRequired(raw))) return response; + const headers = new Headers(response.headers); + headers.delete("content-length"); + headers.set("content-type", "text/html; charset=utf-8"); + headers.set("cache-control", "no-store"); + headers.set("referrer-policy", "no-referrer"); + headers.set( + "content-security-policy", + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'", + ); + // The callback stays in this tab. No provider code or state enters another URL, + // frontend telemetry, browser storage, or the verification tab's referrer. + return new Response( + `Verify admin access · Executor

Verify admin access

Verify with your authenticator in a new tab. Then return here to finish connecting your account.

Verify admin accessContinue connection`, + { status: 200, headers }, + ); +}; diff --git a/apps/cloud/src/start.ts b/apps/cloud/src/start.ts index aaadc1a521..9873aa992c 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -6,6 +6,7 @@ import { authGateMiddleware } from "./auth/doc-gate"; import { parseCookie } from "./auth/cookies"; import { ORG_SELECTOR_HEADER } from "./auth/organization"; import { loginPath } from "./auth/return-to"; +import { oauthAdminVerificationResponse } from "./auth/oauth-admin-verification"; import { prepareMcpOrgScope } from "./mcp/mount"; import { docsProxyMiddleware, @@ -91,7 +92,10 @@ const appRequestMiddleware = createMiddleware({ type: "request" }).server( if (isAppOwnedPath(pathname)) { const scopedRequest = pathname === OAUTH_CALLBACK_PATH ? oauthCallbackOrgScopedRequest(request) : request; - return (await getApp()).handler(prepareMcpOrgScope(scopedRequest)); + const response = await (await getApp()).handler(prepareMcpOrgScope(scopedRequest)); + return pathname === OAUTH_CALLBACK_PATH + ? oauthAdminVerificationResponse(request, response) + : response; } return next(); }, diff --git a/e2e/cloud/connection-owner-isolation.test.ts b/e2e/cloud/connection-owner-isolation.test.ts index 4a04b3bad7..c4ef349b0a 100644 --- a/e2e/cloud/connection-owner-isolation.test.ts +++ b/e2e/cloud/connection-owner-isolation.test.ts @@ -21,6 +21,8 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ import { scenario } from "../src/scenario"; import { Api, Target } from "../src/services"; import type { Identity, Target as TargetShape } from "../src/target"; +import { withRefreshedSession } from "./support/session"; +import { verifyAdmin } from "./support/admin-mfa"; const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; @@ -85,23 +87,6 @@ const postJson = (target: TargetShape, path: string, identity: Identity, body: u return response; }); -/** The identity re-bound to the refreshed session cookie a response set, - * scoped to `orgSelector` via the selector header (see switchOrg). */ -const withRefreshedSession = ( - identity: Identity, - response: Response, - orgSelector: string, -): Identity => { - const refreshed = (response.headers.getSetCookie?.() ?? []) - .find((header) => header.startsWith("wos-session=")) - ?.split(";")[0]; - if (!refreshed) throw new Error("response did not refresh the session cookie"); - return { - ...identity, - headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector }, - }; -}; - /** The org selector this identity's requests carry — the same header the web * client derives from the console URL (identities minted with an org carry * it; org-scoped reads fail closed without one). */ @@ -130,7 +115,7 @@ const createAnotherOrg = (target: TargetShape, identity: Identity, name: string) Effect.gen(function* () { const response = yield* postJson(target, "/api/auth/create-organization", identity, { name }); const created = (yield* Effect.promise(() => response.clone().json())) as { id: string }; - return withRefreshedSession(identity, response, created.id); + return yield* verifyAdmin(target.baseUrl, withRefreshedSession(identity, response, created.id)); }); // `/api/auth/switch-organization` (session-cookie-based org switching) was diff --git a/e2e/cloud/oauth-callback-org-scope.test.ts b/e2e/cloud/oauth-callback-org-scope.test.ts index 66f1f50415..860781e30b 100644 --- a/e2e/cloud/oauth-callback-org-scope.test.ts +++ b/e2e/cloud/oauth-callback-org-scope.test.ts @@ -18,6 +18,7 @@ import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; import type { Identity } from "../src/target"; import { visit } from "../src/surfaces/browser"; +import { verifyAdminInBrowser } from "./support/admin-mfa"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -220,6 +221,7 @@ scenario( await step("The browser session is switched to another organization", async () => { await setWorkosSessionCookie(page, target.baseUrl, sessionB); await visit(page, `/${orgB.slug}`); + await verifyAdminInBrowser(page, identity.credentials?.totpSecret); await expectOrgShell(page, orgB); }); diff --git a/e2e/cloud/oauth-callback-unauthenticated.test.ts b/e2e/cloud/oauth-callback-unauthenticated.test.ts index e4e4df56bf..ba8078f4b5 100644 --- a/e2e/cloud/oauth-callback-unauthenticated.test.ts +++ b/e2e/cloud/oauth-callback-unauthenticated.test.ts @@ -14,6 +14,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { verifyAdminInBrowser } from "./support/admin-mfa"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -134,9 +135,22 @@ scenario( await page.waitForURL((url) => url.pathname === "/api/oauth/callback", { timeout: 30_000, }); - await page.waitForFunction(() => document.body.innerText.includes("Connected"), null, { - timeout: 30_000, - }); + await page.getByRole("heading", { name: "Verify admin access" }).waitFor(); + expect(await page.locator("body").innerText()).not.toContain("Connected"); + }); + + await step("Verify admin access and finish the pending connection", async () => { + const [verification] = await Promise.all([ + page.context().waitForEvent("page"), + page.getByRole("link", { name: "Verify admin access", exact: true }).click(), + ]); + try { + await verifyAdminInBrowser(verification, identity.credentials?.totpSecret); + } finally { + await verification.close(); + } + await page.getByRole("link", { name: "Continue connection" }).click(); + await page.getByText("Connected", { exact: true }).waitFor(); }); const body = (await page.locator("body").textContent())?.trim() ?? ""; diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts index bb3f9f187c..e68530314f 100644 --- a/e2e/cloud/org-delete.test.ts +++ b/e2e/cloud/org-delete.test.ts @@ -10,6 +10,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; import { visit, settle } from "../src/surfaces/browser"; +import { verifyAdminInBrowser } from "./support/admin-mfa"; scenario( "Organizations · an admin deletes the organization from settings", @@ -40,6 +41,7 @@ scenario( await step("Open Organization settings and find the danger zone", async () => { await visit(page, `/${slug}/org`); + await verifyAdminInBrowser(page); // The admin-only danger zone renders (a member would not see it). await page.getByText("Permanently delete this organization").waitFor(); }); diff --git a/e2e/cloud/session-gate.test.ts b/e2e/cloud/session-gate.test.ts index 7868c872af..ced114b232 100644 --- a/e2e/cloud/session-gate.test.ts +++ b/e2e/cloud/session-gate.test.ts @@ -13,6 +13,7 @@ import * as Iron from "iron-webcrypto"; import { scenario } from "../src/scenario"; import { Api, Target } from "../src/services"; import { E2E_COOKIE_PASSWORD } from "../targets/cloud"; +import { browserCookies } from "./support/admin-mfa"; /** A signed-out-style document request (what the gate keys on). */ const documentRequest = (url: URL, cookie?: string) => @@ -115,7 +116,9 @@ scenario( // waiting out a real expiry. Same sealing library + password map the WorkOS // SDK uses, so the gate can't tell this seal from one the SDK minted. const withTamperedAccessToken = async (sessionCookie: string): Promise => { - const sealed = sessionCookie.slice("wos-session=".length).replace(/~\d$/, ""); + const cookie = browserCookies(sessionCookie).find((entry) => entry.name === "wos-session"); + if (!cookie) throw new Error("Test identity has no WorkOS session"); + const sealed = cookie.value.replace(/~\d$/, ""); const session = (await Iron.unseal(sealed, { "1": E2E_COOKIE_PASSWORD }, Iron.defaults)) as { accessToken: string; }; diff --git a/e2e/cloud/support/admin-mfa.ts b/e2e/cloud/support/admin-mfa.ts index ba88e27690..3db7a4ca47 100644 --- a/e2e/cloud/support/admin-mfa.ts +++ b/e2e/cloud/support/admin-mfa.ts @@ -1,9 +1,12 @@ import { Effect, Option, Schema } from "effect"; import { TOTP } from "otpauth"; +import type { Page } from "playwright"; import type { Identity } from "../../src/target"; const Setup = Schema.Struct({ kind: Schema.Literal("enroll"), secret: Schema.String }); const decodeSetup = Schema.decodeUnknownOption(Setup); +const Challenge = Schema.Struct({ kind: Schema.Literal("challenge") }); +const decodeChallenge = Schema.decodeUnknownOption(Challenge); const Verified = Schema.Struct({ verified: Schema.Literal(true) }); const decodeVerified = Schema.decodeUnknownOption(Verified); @@ -39,8 +42,8 @@ export const browserCookies = (cookie: string): NonNullable }; }); -/** Enroll a fresh test admin through the real product and the installed WorkOS emulator. */ -export const verifyFreshAdmin = (baseUrl: string, identity: Identity): Effect.Effect => +/** Verify a test admin through the product, retaining the test authenticator for later sign-ins. */ +export const verifyAdmin = (baseUrl: string, identity: Identity): Effect.Effect => Effect.promise(async () => { const headers = { ...identity.headers, @@ -53,13 +56,17 @@ export const verifyFreshAdmin = (baseUrl: string, identity: Identity): Effect.Ef body: "{}", }); if (!started.ok) throw new Error(`Admin enrollment failed (${started.status})`); - const setup = Option.getOrNull(decodeSetup(await started.json())); - if (!setup) throw new Error("Expected a fresh MFA enrollment"); + const raw: unknown = await started.json(); + const setup = Option.getOrNull(decodeSetup(raw)); + const secret = + setup?.secret ?? + (Option.isSome(decodeChallenge(raw)) ? identity.credentials?.totpSecret : undefined); + if (!secret) throw new Error("Missing test authenticator"); const pending = responseCookies(identity.headers?.cookie ?? "", started); const verified = await fetch(new URL("/api/auth/admin-mfa/verify", baseUrl), { method: "POST", headers: { ...headers, cookie: pending }, - body: JSON.stringify({ code: new TOTP({ secret: setup.secret }).generate() }), + body: JSON.stringify({ code: new TOTP({ secret }).generate() }), }); if (!verified.ok || Option.isNone(decodeVerified(await verified.json()))) throw new Error(`Admin verification failed (${verified.status})`); @@ -73,5 +80,31 @@ export const verifyFreshAdmin = (baseUrl: string, identity: Identity): Effect.Ef ...identity, headers: { ...identity.headers, cookie }, cookies: browserCookies(cookie), + ...(identity.credentials + ? { credentials: { ...identity.credentials, totpSecret: secret } } + : {}), }; }); + +/** Complete the visible MFA prompt using enrollment or this test identity's authenticator. */ +export const verifyAdminInBrowser = async (page: Page, secret?: string): Promise => { + await page.getByRole("heading", { name: "Verify to use admin settings" }).waitFor(); + const [started] = await Promise.all([ + page.waitForResponse((response) => response.url().endsWith("/api/auth/admin-mfa/start")), + page.getByRole("button", { name: "Continue", exact: true }).click(), + ]); + const raw: unknown = await started.json(); + const setup = Option.getOrNull(decodeSetup(raw)); + const key = setup?.secret ?? (Option.isSome(decodeChallenge(raw)) ? secret : undefined); + if (!started.ok() || !key) throw new Error("Could not open the test authenticator"); + await page.getByLabel("Six-digit code").fill(new TOTP({ secret: key }).generate()); + const [verified] = await Promise.all([ + page.waitForResponse((response) => response.url().endsWith("/api/auth/admin-mfa/verify")), + page.getByRole("button", { name: "Verify", exact: true }).click(), + ]); + if (!verified.ok() || Option.isNone(decodeVerified(await verified.json()))) + throw new Error("Browser admin verification failed"); + await page + .getByRole("heading", { name: "Verify to use admin settings" }) + .waitFor({ state: "detached" }); +}; diff --git a/e2e/src/target.ts b/e2e/src/target.ts index 6d6c809137..0ad050c228 100644 --- a/e2e/src/target.ts +++ b/e2e/src/target.ts @@ -27,7 +27,11 @@ export interface Identity { readonly secure?: boolean; }>; /** Credentials for surfaces that sign in themselves (Better Auth, OAuth consent). */ - readonly credentials?: { readonly email: string; readonly password: string }; + readonly credentials?: { + readonly email: string; + readonly password: string; + readonly totpSecret?: string; + }; } export interface Target { diff --git a/e2e/targets/cloud.ts b/e2e/targets/cloud.ts index b0f7828b32..1bed3701bf 100644 --- a/e2e/targets/cloud.ts +++ b/e2e/targets/cloud.ts @@ -10,7 +10,7 @@ import { Effect } from "effect"; import { connectEmulator } from "@executor-js/emulate"; -import { verifyFreshAdmin } from "../cloud/support/admin-mfa"; +import { verifyAdmin } from "../cloud/support/admin-mfa"; import { e2ePort } from "../src/ports"; import type { Identity, Target } from "../src/target"; @@ -110,7 +110,7 @@ export const cloudTarget = (): Target => ({ credentials: { email, password: "emulated" }, }; return org && adminMfa - ? await Effect.runPromise(verifyFreshAdmin(CLOUD_BASE_URL, identity)) + ? await Effect.runPromise(verifyAdmin(CLOUD_BASE_URL, identity)) : identity; }), // MCP OAuth against the emulator's authorization server: complete the From cac34277b841d8c493d68a30ff642557e9a4491f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:46:32 -0700 Subject: [PATCH 3/3] Check persisted MFA state after browser reload --- e2e/cloud/support/admin-mfa.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/e2e/cloud/support/admin-mfa.ts b/e2e/cloud/support/admin-mfa.ts index 3db7a4ca47..9622ec8af7 100644 --- a/e2e/cloud/support/admin-mfa.ts +++ b/e2e/cloud/support/admin-mfa.ts @@ -9,6 +9,9 @@ const Challenge = Schema.Struct({ kind: Schema.Literal("challenge") }); const decodeChallenge = Schema.decodeUnknownOption(Challenge); const Verified = Schema.Struct({ verified: Schema.Literal(true) }); const decodeVerified = Schema.decodeUnknownOption(Verified); +const decodeVerifiedState = Schema.decodeUnknownOption( + Schema.Struct({ state: Schema.Literal("verified") }), +); /** Apply response cookie rotations and deletions to a test client's cookie header. */ export const responseCookies = (current: string, response: Response): string => { @@ -102,9 +105,17 @@ export const verifyAdminInBrowser = async (page: Page, secret?: string): Promise page.waitForResponse((response) => response.url().endsWith("/api/auth/admin-mfa/verify")), page.getByRole("button", { name: "Verify", exact: true }).click(), ]); - if (!verified.ok() || Option.isNone(decodeVerified(await verified.json()))) - throw new Error("Browser admin verification failed"); + if (!verified.ok()) throw new Error("Browser admin verification failed"); await page .getByRole("heading", { name: "Verify to use admin settings" }) .waitFor({ state: "detached" }); + // Successful verification reloads the document, so Chromium can discard that + // response body. Check the persisted session through the product instead. + const selector = new URL(page.url()).pathname.split("/")[1]; + if (!selector) throw new Error("Admin verification has no organization scope"); + const status = await page.request.get("/api/auth/admin-mfa", { + headers: { "x-executor-organization": selector }, + }); + if (!status.ok() || Option.isNone(decodeVerifiedState(await status.json()))) + throw new Error("The browser session is not verified"); };