diff --git a/.changeset/organization-settings-verification.md b/.changeset/organization-settings-verification.md new file mode 100644 index 0000000000..bae2246529 --- /dev/null +++ b/.changeset/organization-settings-verification.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +Require authenticator verification for organization settings while preserving shared workspace reads and API-key access. diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts index d9aaf70d27..99a609877a 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -1,5 +1,7 @@ +import { env } from "cloudflare:workers"; +import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "../auth/admin-mfa-proof"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; -import { Effect, Layer } from "effect"; +import { Clock, Effect, Layer } from "effect"; import { AccountProvider, @@ -75,6 +77,16 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ // session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie). const session: Session | null = resolved ? sessionFromSealed(resolved, "") : null; + const proof = resolved + ? yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId: resolved.userId, sessionId: resolved.sessionId }, + "verified", + request.cookies[ADMIN_MFA_COOKIE], + yield* Clock.currentTimeMillis, + ) + : null; + // Built inside the request body so the WorkOS account service closes // over the per-request `UserStoreService` (postgres socket) supplied by // the combined request-scoped layer. `local` keeps that promise: the @@ -84,7 +96,7 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ AccountProvider.asEffect(), workosAccountProvider.pipe( Layer.provide(ApiKeyService.WorkOS), - Layer.provide(Layer.succeed(AccountCaller)({ session })), + Layer.provide(Layer.succeed(AccountCaller)({ session, adminVerified: proof !== null })), ), { local: true }, ); 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..a43456e1ed 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 @@ -196,7 +196,7 @@ const providerWith = (accountId: string) => { stubDirectory, stubApiKeys, stubAutumn, - Layer.succeed(AccountCaller)({ session: session(accountId) }), + Layer.succeed(AccountCaller)({ session: session(accountId), adminVerified: false }), ), ), ), diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts index 94f5aed511..50fedfa23c 100644 --- a/apps/cloud/src/account/workos-account-service.ts +++ b/apps/cloud/src/account/workos-account-service.ts @@ -31,7 +31,7 @@ import { // the same `WorkOSClient.authenticateSealedSession` the rest of cloud uses. export class AccountCaller extends Context.Service< AccountCaller, - { readonly session: Session | null } + { readonly session: Session | null; readonly adminVerified?: boolean } >()("@executor-js/cloud/AccountCaller") {} // --------------------------------------------------------------------------- @@ -146,6 +146,16 @@ export const workosAccountProvider: Layer.Layer< const requireAdmin = (org: { readonly memberRole: "admin" | "member" }) => org.memberRole === "admin" ? Effect.void : Effect.fail(new AccountForbidden()); + // Settings mutations require MFA; shared member reads and key management do not. + const requireVerifiedSettings = (org: { readonly memberRole: "admin" | "member" }) => + Effect.gen(function* () { + yield* requireAdmin(org); + if (caller.adminVerified !== true) + return yield* new AccountForbidden({ + message: "Verify your identity to change organization 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 — // revoking a pending invite is a delete too). One point read on the @@ -390,7 +400,7 @@ export const workosAccountProvider: Layer.Layer< inviteMember: (headers, body) => Effect.gen(function* () { const { org } = yield* requireOrganization(headers); - yield* requireAdmin(org); + yield* requireVerifiedSettings(org); yield* reserveMemberSlot(org.id); const invitation = yield* workos .sendInvitation({ @@ -422,7 +432,7 @@ export const workosAccountProvider: Layer.Layer< removeMember: (headers, membershipId) => Effect.gen(function* () { const { org } = yield* requireOrganization(headers); - yield* requireAdmin(org); + yield* requireVerifiedSettings(org); const membership = yield* assertMembershipInOrg(org.id, membershipId); yield* workos .deleteOrgMembership(membershipId) @@ -453,7 +463,7 @@ export const workosAccountProvider: Layer.Layer< updateMemberRole: (headers, membershipId, roleSlug) => Effect.gen(function* () { const { org } = yield* requireOrganization(headers); - yield* requireAdmin(org); + yield* requireVerifiedSettings(org); yield* assertMembershipInOrg(org.id, membershipId); const updated = yield* workos .updateOrgMembershipRole(membershipId, roleSlug) @@ -467,7 +477,7 @@ export const workosAccountProvider: Layer.Layer< updateOrgName: (headers, name) => Effect.gen(function* () { const { org } = yield* requireOrganization(headers); - yield* requireAdmin(org); + yield* requireVerifiedSettings(org); const updated = yield* workos .updateOrganization(org.id, name) .pipe(Effect.catchTag("WorkOSError", toAccountError)); 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/admin-mfa-proof.test.ts b/apps/cloud/src/auth/admin-mfa-proof.test.ts new file mode 100644 index 0000000000..96fa7a8f03 --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-proof.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { SignJWT } from "jose"; +import { readAdminMfaProof, signAdminMfaProof } from "./admin-mfa-proof"; + +const secret = "a-test-only-cookie-password-of-32-characters"; +const identity = { userId: "user_test", sessionId: "session_test" }; +const now = 1_800_000_000_000; +const proof = { + mode: "challenge" as const, + factorId: "factor_test", + challengeId: "challenge_test", + exp: now / 1000 + 900, +}; +const signed = signAdminMfaProof(secret, identity, "verified", proof, now); + +describe("admin verification cookie", () => { + it.effect("accepts a valid proof for the same user and session", () => + Effect.gen(function* () { + const token = yield* signed; + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toEqual(proof); + }), + ); + + it.effect("refuses missing, modified, and unsigned cookies", () => + Effect.gen(function* () { + const token = yield* signed; + const parts = token.split("."); + const unsigned = `${btoa('{"alg":"none"}')}.${parts[1]}.`; + for (const value of [ + undefined, + "", + "bad.cookie", + `${token.slice(0, 50)}x${token.slice(51)}`, + unsigned, + ]) { + expect(yield* readAdminMfaProof(secret, identity, "verified", value, now)).toBeNull(); + } + }), + ); + + it.effect("refuses another session, another user, and another signing key", () => + Effect.gen(function* () { + const token = yield* signed; + for (const other of [ + { ...identity, userId: "other" }, + { ...identity, sessionId: "other" }, + ]) { + expect(yield* readAdminMfaProof(secret, other, "verified", token, now)).toBeNull(); + } + expect( + yield* readAdminMfaProof(`${secret}-rotated`, identity, "verified", token, now), + ).toBeNull(); + }), + ); + + it.effect("cannot promote an unfinished challenge to verified access", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "challenge", + { ...proof, mode: "enroll", exp: now / 1000 + 300 }, + now, + ); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "challenge", token, now + 299_000), + ).not.toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "challenge", token, now + 300_000), + ).toBeNull(); + }), + ); + + it.effect("honors the signed expiration and refuses a future-issued cookie", () => + Effect.gen(function* () { + const token = yield* signed; + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 899_000), + ).not.toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 900_000), + ).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now - 10_000), + ).toBeNull(); + }), + ); + + it.effect("keeps the verified session unlocked beyond the former fifteen-minute window", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "verified", + { + ...proof, + exp: now / 1000 + 7 * 86400, + }, + now, + ); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 3600_000), + ).not.toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 7 * 86400_000), + ).toBeNull(); + }), + ); + + it.effect("caps token age even when the supplied expiration is longer", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "verified", + { ...proof, exp: now / 1000 + 8 * 86400 }, + now, + ); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 901_000), + ).toBeNull(); + }), + ); + + it.effect("rejects a signed cookie with missing issued-at or another algorithm", () => + Effect.gen(function* () { + for (const algorithm of ["HS256", "HS384"]) { + const jwt = new SignJWT({ ...proof }) + .setProtectedHeader({ alg: algorithm }) + .setIssuer("executor:admin-mfa:verified") + .setSubject(identity.userId) + .setAudience(identity.sessionId); + // HS256 lacks iat; HS384 is otherwise valid but outside the allowlist. + if (algorithm === "HS384") jwt.setIssuedAt(now / 1000); + const token = yield* Effect.promise(() => jwt.sign(new TextEncoder().encode(secret))); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + } + }), + ); +}); 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..33f06f8d24 --- /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"; +/** Verification is session-bound, with a seven-day maximum matching the login cookie. */ +export const ADMIN_MFA_TTL_SECONDS = 7 * 24 * 60 * 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..107eb2edc3 --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-routes.ts @@ -0,0 +1,244 @@ +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, +}; +const json = (body: unknown, status = 200) => + HttpServerResponse.jsonUnsafe(body, { status, headers: { "cache-control": "no-store" } }); + +const handler = (action: "status" | "start" | "verify" | "cancel" | "lock") => + 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") { + const proof = yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId: session.userId, sessionId: session.sessionId }, + "verified", + request.cookies[ADMIN_MFA_COOKIE], + yield* Clock.currentTimeMillis, + ); + return json( + proof !== null ? { state: "verified", expiresAt: proof.exp } : { state: "required" }, + ); + } + + if (action === "lock") { + return json({ canceled: true }).pipe( + HttpServerResponse.setCookieUnsafe(ADMIN_MFA_COOKIE, "", { + ...cookieOptions, + maxAge: Duration.seconds(0), + }), + HttpServerResponse.setCookieUnsafe(ADMIN_MFA_CHALLENGE_COOKIE, "", { + ...cookieOptions, + maxAge: Duration.seconds(0), + }), + ); + } + 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, + sameSite: "strict", + }), + 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")), + HttpRouter.add("POST", "/api/auth/admin-mfa/lock", handler("lock")), +); diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 6453a0547f..b378771af7 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -1,3 +1,4 @@ +import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "./admin-mfa-proof"; import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { Clock, Duration, Effect, Predicate } from "effect"; @@ -556,6 +557,18 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // earlier attempt failed after the mark can send it again and finish. const session = yield* selectedOrganization({ deleted: "allow" }); const organizationId = session.organizationId; + const request = yield* HttpServerRequest.HttpServerRequest; + const verifiedSession = yield* workos.authenticateSealedSession(session.sealedSession); + const proof = verifiedSession + ? yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId: verifiedSession.userId, sessionId: verifiedSession.sessionId }, + "verified", + request.cookies[ADMIN_MFA_COOKIE], + yield* Clock.currentTimeMillis, + ) + : null; + if (!proof) return yield* new OrganizationDeletionForbidden(); // Admin-only. `requireSelectedOrganization` already read the caller's // mirrored membership, required it ACTIVE (a pending admin invite is diff --git a/apps/cloud/src/auth/mirror-feeders.node.test.ts b/apps/cloud/src/auth/mirror-feeders.node.test.ts index b1cb9ba1de..d166e49b83 100644 --- a/apps/cloud/src/auth/mirror-feeders.node.test.ts +++ b/apps/cloud/src/auth/mirror-feeders.node.test.ts @@ -1,3 +1,4 @@ +import { verifiedSettingsCookie } from "../../test-stubs/verified-settings"; // --------------------------------------------------------------------------- // The membership mirror's FEEDERS, end to end through the code that runs in // production, against the real PGlite Postgres every cloud unit test runs on @@ -594,6 +595,7 @@ describe("session handlers read membership from the mirror", () => { authenticateSealedSession: () => Effect.succeed({ userId, + sessionId: "test-settings-session", email: `${userId}@placeholder.test`, organizationId: null, } as never), @@ -710,11 +712,11 @@ describe("session handlers read membership from the mirror", () => { return slug; }; - const deleteOrganizationRequest = (org: string) => + const deleteOrganizationRequest = async (org: string, userId: string) => new Request("http://test.local/auth/delete-organization", { method: "POST", headers: { - cookie: "wos-session=sealed", + cookie: `wos-session=sealed; ${await verifiedSettingsCookie(userId)}`, "content-type": "application/json", [ORG_SELECTOR_HEADER]: org, }, @@ -761,7 +763,7 @@ describe("session handlers read membership from the mirror", () => { // requires an ACTIVE membership, so the invite grants no deletion right. await seedMembership(userId, org, "pending", "admin"); - const response = await sessionHandler(userId)(deleteOrganizationRequest(org)); + const response = await sessionHandler(userId)(await deleteOrganizationRequest(org, userId)); // The selector resolves no active membership, so the request fails at the // org check (NoOrganization) — the handler never reaches the WorkOS @@ -775,7 +777,7 @@ describe("session handlers read membership from the mirror", () => { const org = freshId("org"); await seedMembership(userId, org, "active", "member"); - const response = await sessionHandler(userId)(deleteOrganizationRequest(org)); + const response = await sessionHandler(userId)(await deleteOrganizationRequest(org, userId)); expect(response.status).toBe(403); expect( @@ -805,7 +807,7 @@ describe("session handlers read membership from the mirror", () => { }), }, }); - const first = await failing(deleteOrganizationRequest(org)); + const first = await failing(await deleteOrganizationRequest(org, admin)); expect(first.status, "the failed purge is surfaced, not hidden").toBe(500); expect(workosDeletes).toEqual([org]); expect(purges).toEqual(["deleteOrganizationCascade"]); @@ -825,7 +827,7 @@ describe("session handlers read membership from the mirror", () => { deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })), }, }); - const second = await retry(deleteOrganizationRequest(org)); + const second = await retry(await deleteOrganizationRequest(org, admin)); expect(second.status, "the admin's own membership still admits the retry").toBe(200); expect(await second.json()).toEqual({ success: true }); expect( @@ -879,7 +881,7 @@ describe("session handlers read membership from the mirror", () => { }, }); - const first = await handler(deleteOrganizationRequest(org)); + const first = await handler(await deleteOrganizationRequest(org, admin)); expect(first.status, "the failed billing cancel is surfaced, not hidden").toBe(500); expect(await first.json()).toMatchObject({ _tag: "OrganizationDeletionIncomplete", @@ -894,7 +896,7 @@ describe("session handlers read membership from the mirror", () => { ).toEqual([admin, member].sort()); expect(await authorized(member, org), "yet nobody is authorized: the mark stands").toBe(false); - const second = await handler(deleteOrganizationRequest(org)); + const second = await handler(await deleteOrganizationRequest(org, admin)); expect(second.status, "the admin's own membership row still admits the retry").toBe(200); expect(await second.json()).toEqual({ success: true }); expect(workosDeletes, "WorkOS is asked once billing is cancelled").toEqual([org]); @@ -920,7 +922,7 @@ describe("session handlers read membership from the mirror", () => { services: servicesWithFailingPurge(purges), autumn: deletingAutumn, workos: { deleteOrganization: () => Effect.void }, - })(deleteOrganizationRequest(org)); + })(await deleteOrganizationRequest(org, admin)); expect(first.status).toBe(500); expect(purges).toEqual(["deleteOrganizationCascade"]); @@ -933,7 +935,7 @@ describe("session handlers read membership from the mirror", () => { deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })), }, }); - const second = await retry(deleteOrganizationRequest(org)); + const second = await retry(await deleteOrganizationRequest(org, admin)); expect(second.status, "the retry is admitted from the mirror").toBe(200); expect(await second.json()).toEqual({ success: true }); expect(await readMembers(org), "and the purge ran").toEqual([]); @@ -1158,7 +1160,7 @@ describe("account service writes through to the mirror", () => { workos, stubApiKeys, options.autumn ?? stubAutumn, - Layer.succeed(AccountCaller)({ session: session(ADMIN) }), + Layer.succeed(AccountCaller)({ session: session(ADMIN), adminVerified: true }), ), ), Layer.provideMerge(stores), diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 918a7e8556..af37f69218 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -431,6 +431,17 @@ const make = Effect.gen(function* () { tryPromiseService(() => fn(workos)), ); + // 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) => Effect.gen(function* () { if (!sessionData) return null; @@ -482,6 +493,31 @@ const make = Effect.gen(function* () { }); 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", 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/routes.ts b/apps/cloud/src/extensions/routes.ts index f1c4389fe7..9892af061d 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -38,6 +38,7 @@ import { NonProtectedApi, } from "../auth/handlers"; import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; +import { AdminMfaRoutes } from "../auth/admin-mfa-routes"; import { SessionAuthLive } from "../auth/middleware-live"; import { runWorkOsEventsSync } from "../auth/workos-events-runner"; import { makeWorkOsWebhookRoute } from "../auth/workos-webhook"; @@ -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..848f347889 100644 --- a/apps/cloud/src/org/auth-middleware.ts +++ b/apps/cloud/src/org/auth-middleware.ts @@ -1,4 +1,6 @@ -import { Context, Effect, Layer } from "effect"; +import { env } from "cloudflare:workers"; +import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "../auth/admin-mfa-proof"; +import { Clock, Context, Effect, Layer } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { @@ -71,6 +73,19 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ ); if (!org) return noOrganization(); + const proof = yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId: result.userId, sessionId: result.sessionId }, + "verified", + request.cookies[ADMIN_MFA_COOKIE], + yield* Clock.currentTimeMillis, + ); + if (!proof) + return HttpServerResponse.jsonUnsafe( + { _tag: "Forbidden", message: "Verify your identity to open organization settings." }, + { status: 403, headers: { "cache-control": "no-store" } }, + ); + const session = sessionFromSealed(result, cookieValue); const auth = AuthContext.of({ accountId: session.accountId, diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index 1cae2aa9a2..b5c99af906 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -1,3 +1,4 @@ +import { verifiedSettingsCookie } from "../../test-stubs/verified-settings"; import { afterAll, describe, expect, it } from "@effect/vitest"; import { Data, Effect, Layer } from "effect"; import { HttpRouter, HttpServer } from "effect/unstable/http"; @@ -239,6 +240,7 @@ const workosForCaller = (deleted: string[]) => authenticateSealedSession: () => Effect.succeed({ userId: CALLER, + sessionId: "test-settings-session", email: "caller@placeholder.test", organizationId: ORG, }), @@ -274,7 +276,10 @@ const deleteDomain = async (role: "admin" | "member") => { const response = await app.handler( new Request(`https://executor.test/org/domains/${DOMAIN}`, { method: "DELETE", - headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG }, + headers: { + cookie: `wos-session=sealed; ${await verifiedSettingsCookie(CALLER)}`, + [ORG_SELECTOR_HEADER]: ORG, + }, }), // beta.59: the handler type expects a context argument; this layer stack // needs none at runtime — pass undefined like the api.request-scope tests. diff --git a/apps/cloud/src/routes/app/org.tsx b/apps/cloud/src/routes/app/org.tsx index a9ded0b7c8..15af2e2f7e 100644 --- a/apps/cloud/src/routes/app/org.tsx +++ b/apps/cloud/src/routes/app/org.tsx @@ -1,3 +1,4 @@ +import { AdminVerification } from "../../web/components/admin-verification"; import { useState } from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { Exit } from "effect"; @@ -56,6 +57,14 @@ type DomainData = { }; function OrgPage() { + return ( + + + + ); +} + +function OrganizationSettings() { return (
{/* Shared members / roles / invite / org-name surface. */} 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..2662a22f56 --- /dev/null +++ b/apps/cloud/src/web/components/admin-verification.tsx @@ -0,0 +1,278 @@ +import { Link } from "@tanstack/react-router"; +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("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 the organization settings page. */ +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" | "lock") => { + const signal = controller.current?.signal; + if (!signal || signal.aborted || busy) return; + setBusy(true); + setError(null); + if (action === "lock") { + await run(request("/lock", decodeCanceled, {}), signal, () => window.location.reload()); + } else 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 === "verified") + return ( + <> +
+ Organization settings are unlocked for this session.{" "} + +
+ {children} + + ); + + return ( +
+

Unlock organization settings

+

+ Use an authenticator app to open organization settings for this session. +

+ previous} + className="mt-3 block text-sm underline" + > + Back to workspace + + {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/test-stubs/verified-settings.ts b/apps/cloud/test-stubs/verified-settings.ts new file mode 100644 index 0000000000..d5f2f17200 --- /dev/null +++ b/apps/cloud/test-stubs/verified-settings.ts @@ -0,0 +1,23 @@ +import { env } from "cloudflare:workers"; +import { Effect } from "effect"; +import { ADMIN_MFA_COOKIE, signAdminMfaProof } from "../src/auth/admin-mfa-proof"; + +/** A signed proof for HTTP fixtures; the WorkOS stub must return this session id. */ +export const verifiedSettingsCookie = async (userId: string): Promise => { + const now = Date.now(); + const proof = await Effect.runPromise( + signAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId, sessionId: "test-settings-session" }, + "verified", + { + factorId: "test-factor", + challengeId: "test-challenge", + mode: "challenge", + exp: now / 1000 + 900, + }, + now, + ), + ); + return `${ADMIN_MFA_COOKIE}=${proof}`; +}; diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 6d92064589..4ee8b7a2b4 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -28,6 +28,13 @@ "observability": { "enabled": 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 87589fabdd..78ee6bcded 100644 --- a/bun.lock +++ b/bun.lock @@ -356,7 +356,7 @@ "version": "0.0.50", "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=="], @@ -3521,6 +3522,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=="], @@ -3765,6 +3768,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=="], @@ -3819,6 +3824,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=="], @@ -3899,8 +3906,6 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], - "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -4875,6 +4880,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=="], @@ -4995,7 +5002,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=="], @@ -5077,6 +5084,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=="], @@ -5255,6 +5264,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=="], @@ -5341,6 +5352,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=="], @@ -5755,6 +5768,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=="], @@ -5871,6 +5886,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=="], @@ -5961,6 +5978,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=="], @@ -5969,6 +5988,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=="], @@ -6003,6 +6024,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=="], @@ -6213,6 +6236,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=="], @@ -6475,6 +6500,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=="], @@ -6575,8 +6602,6 @@ "electron-vite/vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], - "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -6741,6 +6766,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=="], @@ -7109,6 +7136,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=="], @@ -7757,6 +7788,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=="], @@ -8005,6 +8044,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=="], @@ -8033,6 +8076,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..e048c9c625 --- /dev/null +++ b/e2e/cloud/admin-mfa-api.test.ts @@ -0,0 +1,131 @@ +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(); + const other = yield* target.newIdentity(); + 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(200); + 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(200); + const billing = await fetch(new URL("/api/billing/getOrCreateCustomer", target.baseUrl), { + method: "POST", + headers, + body: "{}", + }); + expect(billing.status).toBe(200); + 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 proofCookie = verified.headers + .getSetCookie() + .find((cookie) => cookie.startsWith("__Host-executor-admin-mfa=")); + expect(proofCookie).toMatch(/HttpOnly/i); + expect(proofCookie).toMatch(/Secure/i); + expect(proofCookie).toMatch(/SameSite=Strict/i); + expect(proofCookie).not.toMatch(/Max-Age|Expires/i); + 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(); + 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/connection-owner-isolation.test.ts b/e2e/cloud/connection-owner-isolation.test.ts index 4a04b3bad7..a20a2003f5 100644 --- a/e2e/cloud/connection-owner-isolation.test.ts +++ b/e2e/cloud/connection-owner-isolation.test.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./support/admin-mfa"; // Cloud-only: the connection OWNER model, with real multi-user organizations. // Every connection is filed under `owner: "org"` (shared with the whole tenant) // or `owner: "user"` (this subject's own). The org membership is built through @@ -115,7 +116,8 @@ const orgSelectorOf = (identity: Identity): string => { * Returns the member identity with its requests scoped to that org. */ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => Effect.gen(function* () { - const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { + const verifiedAdmin = yield* verifyAdmin(target.baseUrl, admin); + const inviteResponse = yield* postJson(target, "/api/account/members/invite", verifiedAdmin, { email: member.credentials?.email, }); const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; diff --git a/e2e/cloud/mcp-workos-blip-session-survival.test.ts b/e2e/cloud/mcp-workos-blip-session-survival.test.ts index d6a5022aa5..30ae7d3d31 100644 --- a/e2e/cloud/mcp-workos-blip-session-survival.test.ts +++ b/e2e/cloud/mcp-workos-blip-session-survival.test.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./support/admin-mfa"; // Cloud: an MCP session's relationship to WorkOS after the membership mirror. // // Membership is authorized from the local mirror on every /mcp request @@ -181,7 +182,7 @@ scenario( // An admin's org with one plain member, joined through the real invite → // accept flow. The member is the one whose access is revoked. - const admin = yield* target.newIdentity(); + const admin = yield* verifyAdmin(target.baseUrl, yield* target.newIdentity()); const invitee = yield* target.newIdentity({ org: false }); const member = yield* joinOrg(target, admin, invitee); const bearer = yield* mcp.mintBearer(emailOf(member)); diff --git a/e2e/cloud/member-invite-seat-limit.test.ts b/e2e/cloud/member-invite-seat-limit.test.ts index e465c1396e..ee76349525 100644 --- a/e2e/cloud/member-invite-seat-limit.test.ts +++ b/e2e/cloud/member-invite-seat-limit.test.ts @@ -1,3 +1,4 @@ +import { verifyAdminInBrowser } from "./support/admin-mfa"; // Cloud-only (billing): the free plan advertises "Up to 3 members", 3 // INCLUSIVE. A fresh org's admin holds seat 1, so two invites fill seats 2 and // 3; at that point "Invite member" opens an upgrade prompt (linking to billing) @@ -40,6 +41,7 @@ scenario( // A fresh user who owns a brand-new free org: the admin holds seat 1. const identity = yield* target.newIdentity(); + let verifiedCookie = ""; const client = yield* apiClient(AccountHttpApi, identity); yield* browser.session(identity, async ({ page, step }) => { @@ -56,6 +58,10 @@ scenario( await step("Open the organization members page", async () => { await visit(page, `/${slug}/org`); + await verifyAdminInBrowser(page); + verifiedCookie = (await page.context().cookies()) + .map(({ name, value }) => `${name}=${value}`) + .join("; "); await page.getByRole("button", { name: "Invite member" }).waitFor(); }); @@ -114,7 +120,11 @@ scenario( const refused = yield* Effect.promise(() => fetch(new URL("/api/account/members/invite", target.baseUrl), { method: "POST", - headers: { ...(identity.headers ?? {}), "content-type": "application/json" }, + headers: { + ...(identity.headers ?? {}), + cookie: verifiedCookie, + "content-type": "application/json", + }, body: JSON.stringify({ email: "over-the-cap@example.com" }), }), ); diff --git a/e2e/cloud/member-seat-billing-sync.test.ts b/e2e/cloud/member-seat-billing-sync.test.ts index 28922a6dba..059d08d2e4 100644 --- a/e2e/cloud/member-seat-billing-sync.test.ts +++ b/e2e/cloud/member-seat-billing-sync.test.ts @@ -23,6 +23,7 @@ import { AccountHttpApi } from "@executor-js/api"; import { scenario } from "../src/scenario"; import { Api, Autumn, Billing, Mcp, Target } from "../src/services"; import type { Identity } from "../src/target"; +import { verifyAdmin } from "./support/admin-mfa"; // apps/cloud/src/extensions/billing/plans.ts → MEMBER_LIMITS.free, mirrored by // the free plan's members item in autumn.config.ts. @@ -56,7 +57,8 @@ scenario( const identity = yield* target.newIdentity(); const bearer = yield* mcp.mintBearer(emailOf(identity)); const customerId = orgIdOf(bearer); - const client = yield* apiClient(AccountHttpApi, identity); + const verifiedIdentity = yield* verifyAdmin(target.baseUrl, identity); + const client = yield* apiClient(AccountHttpApi, verifiedIdentity); const seats = yield* autumn.expectMemberSeats(customerId, 1); expect(seats.granted, "the free plan's members item grants the advertised seats").toBe( diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts index bb3f9f187c..c096c2ca5b 100644 --- a/e2e/cloud/org-delete.test.ts +++ b/e2e/cloud/org-delete.test.ts @@ -1,3 +1,4 @@ +import { verifyAdminInBrowser } from "./support/admin-mfa"; // Cloud-specific (browser): an admin permanently deletes their organization. // A fresh user creates an org through onboarding, opens Organization settings, // and uses the danger-zone "Delete organization" flow — which requires @@ -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/org-settings-mfa.test.ts b/e2e/cloud/org-settings-mfa.test.ts new file mode 100644 index 0000000000..6647b3d143 --- /dev/null +++ b/e2e/cloud/org-settings-mfa.test.ts @@ -0,0 +1,221 @@ +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; +import { verifyAdmin, verifyAdminInBrowser, responseCookies } from "./support/admin-mfa"; +import { activeOrg, forBrowser, joinOrg } from "./support/session"; + +const decodeKey = Schema.decodeUnknownSync( + Schema.Struct({ id: Schema.String, value: Schema.String }), +); +const decodeUsers = Schema.decodeUnknownSync( + Schema.Struct({ + users: Schema.Array(Schema.Struct({ email: Schema.NullOr(Schema.String) })), + }), +); + +scenario( + "Organization MFA · protects settings without blocking workspace or backend credentials", + {}, + Effect.gen(function* () { + const target = yield* Target; + const locked = yield* target.newIdentity(); + const other = yield* target.newIdentity(); + const org = yield* activeOrg(target, locked); + const unlocked = yield* verifyAdmin(target.baseUrl, locked); + yield* Effect.promise(async () => { + const send = ( + headers: Readonly> | undefined, + method: string, + path: string, + body?: unknown, + ) => + fetch(new URL(path, target.baseUrl), { + method, + headers: { + ...headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + for (const path of [ + "/api/admin/users", + "/api/account/members", + "/api/account/roles", + "/api/policies", + "/api/account/api-keys", + "/api/account/org-api-keys", + ]) { + expect((await send(locked.headers, "GET", path)).status, path).toBe(200); + } + const settings = [ + { method: "PATCH", path: "/api/account/name", body: { name: "Verified workspace" } }, + { + method: "POST", + path: "/api/account/members/invite", + body: { email: "invited@example.com", roleSlug: "member" }, + }, + { method: "DELETE", path: "/api/account/members/membership_missing" }, + { + method: "PATCH", + path: "/api/account/members/membership_missing/role", + body: { roleSlug: "admin" }, + }, + { method: "GET", path: "/api/org/domains" }, + { method: "POST", path: "/api/org/domains/verify-link", body: {} }, + { method: "DELETE", path: "/api/org/domains/domain_missing" }, + { + method: "POST", + path: "/api/auth/delete-organization", + body: { confirmName: "Never delete this fixture" }, + }, + ]; + for (const action of settings) { + expect( + (await send(locked.headers, action.method, action.path, action.body)).status, + action.path, + ).toBe(403); + } + expect( + (await send(unlocked.headers, "PATCH", "/api/account/name", { name: "Verified workspace" })) + .status, + ).toBe(200); + expect((await send(unlocked.headers, "GET", "/api/org/domains")).status).toBe(200); + const proof = unlocked.headers?.cookie + ?.split("; ") + .find((pair) => pair.startsWith("__Host-executor-admin-mfa=")); + if (!proof) throw new Error("Verification returned no proof"); + expect( + ( + await send( + { ...other.headers, cookie: `${other.headers?.cookie}; ${proof}` }, + "PATCH", + "/api/account/name", + { name: "Cross-user attempt" }, + ) + ).status, + ).toBe(403); + + // Key management is on its own screen and remains available without MFA. + const minted = await send(locked.headers, "POST", "/api/account/org-api-keys", { + name: "Backend reader", + }); + expect(minted.status).toBe(200); + const key = decodeKey(await minted.json()); + try { + const lock = await send(unlocked.headers, "POST", "/api/auth/admin-mfa/lock", {}); + expect(lock.status).toBe(200); + const relocked = { + ...unlocked.headers, + cookie: responseCookies(unlocked.headers?.cookie ?? "", lock), + }; + expect( + (await send(relocked, "PATCH", "/api/account/name", { name: "Relocked attempt" })).status, + ).toBe(403); + const email = locked.credentials?.email; + if (!email) throw new Error("Test identity has no email"); + const bearer = { authorization: `Bearer ${key.value}` }; + for (const path of [ + "/api/admin/users", + `/api/admin/users/with-connections?email=${encodeURIComponent(email)}`, + ]) { + const response = await send(bearer, "GET", path); + expect(response.status, path).toBe(200); + expect(decodeUsers(await response.json()).users.map((user) => user.email)).toContain( + email, + ); + } + expect( + ( + await send( + { ...bearer, "x-executor-organization": org.id }, + "PATCH", + "/api/account/name", + { name: "Machine settings attempt" }, + ) + ).status, + ).toBe(401); + } finally { + expect( + (await send(locked.headers, "DELETE", `/api/account/org-api-keys/${key.id}`)).status, + ).toBe(200); + } + }); + }), +); + +scenario( + "Organization MFA · browser unlock is confined to organization settings", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const admin = yield* target.newIdentity(); + const org = yield* activeOrg(target, admin); + yield* browser.session(forBrowser(admin), async ({ page, step }) => { + await step("Open integrations without verifying", async () => { + await visit(page, `/${org.slug}/integrations/add/openapi`); + await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); + }); + await step( + "Organization settings asks for an authenticator before showing controls", + async () => { + await visit(page, `/${org.slug}/org`); + await page.getByRole("heading", { name: "Unlock organization settings" }).waitFor(); + expect(await page.getByLabel("Organization name", { exact: true }).count()).toBe(0); + expect(await page.getByRole("button", { name: "Delete", exact: true }).count()).toBe(0); + }, + ); + await step("Enroll and verify, then edit the organization name", async () => { + await verifyAdminInBrowser(page); + await page.getByLabel("Organization name", { exact: true }).fill("Verified organization"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.getByText("Organization name updated", { exact: true }).waitFor(); + }); + await step("Return to organization settings without another challenge", async () => { + await visit(page, `/${org.slug}/api-keys`); + await page.getByRole("heading", { name: "Personal keys", exact: true }).waitFor(); + await visit(page, `/${org.slug}/org`); + await page.getByLabel("Organization name", { exact: true }).waitFor(); + expect( + await page.getByRole("heading", { name: "Unlock organization settings" }).count(), + ).toBe(0); + }); + await step("Lock settings and keep API key management available", async () => { + await page.getByRole("button", { name: "Lock organization settings" }).click(); + await page.getByRole("heading", { name: "Unlock organization settings" }).waitFor(); + await visit(page, `/${org.slug}/api-keys`); + await page.getByRole("button", { name: "New org key" }).waitFor(); + }); + }); + }), +); + +scenario( + "Organization MFA · verification does not elevate a member to admin", + {}, + Effect.gen(function* () { + const target = yield* Target; + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + const verified = yield* verifyAdmin(target.baseUrl, member); + yield* Effect.promise(async () => { + const headers = { + ...verified.headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + }; + const domains = await fetch(new URL("/api/org/domains", target.baseUrl), { headers }); + expect(domains.status).toBe(200); + const rename = await fetch(new URL("/api/account/name", target.baseUrl), { + method: "PATCH", + headers, + body: JSON.stringify({ name: "Member cannot rename" }), + }); + expect(rename.status).toBe(403); + }); + }), +); diff --git a/e2e/cloud/spec-update-convergence.test.ts b/e2e/cloud/spec-update-convergence.test.ts index 0a9ca49647..7c893b9be6 100644 --- a/e2e/cloud/spec-update-convergence.test.ts +++ b/e2e/cloud/spec-update-convergence.test.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./support/admin-mfa"; // Cloud-only (needs real multi-user organizations): when one member refreshes // a shared integration's spec, a DIFFERENT member's OWN connection converges to // the new tool catalog on that member's next read — not just the editor's. @@ -150,7 +151,8 @@ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => Effect.gen(function* () { const adminSelector = admin.headers?.[ORG_SELECTOR_HEADER]; if (!adminSelector) throw new Error("admin identity carries no org selector header"); - const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { + const verifiedAdmin = yield* verifyAdmin(target.baseUrl, admin); + const inviteResponse = yield* postJson(target, "/api/account/members/invite", verifiedAdmin, { email: member.credentials?.email, }); const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; diff --git a/e2e/cloud/support/admin-mfa.ts b/e2e/cloud/support/admin-mfa.ts new file mode 100644 index 0000000000..2e28cb4248 --- /dev/null +++ b/e2e/cloud/support/admin-mfa.ts @@ -0,0 +1,126 @@ +import { Effect, Option, Schema } from "effect"; +import { TOTP } from "otpauth"; +import type { Page } from "playwright"; +import type { Identity } from "../../src/target"; + +// Each synthetic identity owns a test authenticator, reused for subsequent challenges. +const testAuthenticators = new Map(); + +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); +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 => { + 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 } : {}), + }; + }); + +/** 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 email = identity.credentials?.email; + if (!email) throw new Error("Test identity has no email"); + const headers = { + ...identity.headers, + origin: new URL(baseUrl).origin, + "content-type": "application/json", + }; + const status = await fetch(new URL("/api/auth/admin-mfa", baseUrl), { headers }); + if (status.ok && Option.isSome(decodeVerifiedState(await status.json()))) return identity; + 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 raw: unknown = await started.json(); + const setup = Option.getOrNull(decodeSetup(raw)); + const secret = + setup?.secret ?? + (Option.isSome(decodeChallenge(raw)) ? testAuthenticators.get(email) : undefined); + if (!secret) throw new Error("Missing test authenticator"); + testAuthenticators.set(email, secret); + 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 }).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), + }; + }); + +/** 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: "Unlock organization 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()) throw new Error("Browser admin verification failed"); + await page + .getByRole("heading", { name: "Unlock organization 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"); +}; diff --git a/e2e/cloud/support/session.ts b/e2e/cloud/support/session.ts index cb8b0d6cf7..ef4c59ed18 100644 --- a/e2e/cloud/support/session.ts +++ b/e2e/cloud/support/session.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./admin-mfa"; // Cloud session + membership helpers shared by the scenarios that need MORE // than one identity in an org. // @@ -127,7 +128,8 @@ export const joinOrg = ( options: { readonly roleSlug?: string } = {}, ): Effect.Effect => Effect.gen(function* () { - const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { + const verifiedAdmin = yield* verifyAdmin(target.baseUrl, admin); + const inviteResponse = yield* postJson(target, "/api/account/members/invite", verifiedAdmin, { email: member.credentials?.email, ...(options.roleSlug === undefined ? {} : { roleSlug: options.roleSlug }), }); diff --git a/e2e/package.json b/e2e/package.json index 10f9a8e40f..d1dd4ecf97 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:"