From 512db143da12027b6a3bf4cd95f1923589e263b7 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:52:01 -0700 Subject: [PATCH 1/2] Revert "Test cloud admin verification and ordinary access (#2110)" This reverts commit fec546e784a0a29bfaf1da5bfa8caedf5f611b1c. --- .../account/org-api-key-revoke.node.test.ts | 1 - .../src/admin/admin-users-api.node.test.ts | 36 +-- .../api/protected-api-key-auth.node.test.ts | 2 +- .../src/api/protected-jwt-auth.node.test.ts | 2 +- apps/cloud/src/auth/admin-mfa-proof.test.ts | 122 ---------- .../src/auth/mirror-feeders.node.test.ts | 3 - .../src/auth/oauth-admin-verification.test.ts | 40 ---- .../src/auth/org-selector-auth.node.test.ts | 1 - apps/cloud/src/auth/workos.node.test.ts | 49 +--- apps/cloud/src/mcp/auth-provider.test.ts | 4 +- apps/cloud/src/org/handlers.test.ts | 9 +- bun.lock | 57 +---- e2e/cloud/admin-mfa-api.test.ts | 225 ------------------ e2e/cloud/admin-mfa.test.ts | 122 ---------- e2e/cloud/auth-hint.test.ts | 4 - e2e/cloud/mcp-browser-resume-page.test.ts | 75 ------ .../mcp-workspace-write-permissions.test.ts | 14 +- .../oauth-callback-unauthenticated.test.ts | 205 +++++++--------- e2e/cloud/org-api-keys-console.test.ts | 41 +++- e2e/cloud/org-delete.test.ts | 2 - e2e/cloud/session-gate.test.ts | 5 +- e2e/cloud/support/admin-mfa.ts | 119 --------- e2e/cloud/support/session.ts | 13 +- e2e/cloud/workspace-write-permissions.test.ts | 12 - e2e/package.json | 3 +- e2e/src/surfaces/browser.ts | 11 +- e2e/src/target.ts | 17 +- e2e/targets/cloud.ts | 8 +- packages/core/sdk/src/org-writes.test.ts | 10 +- packages/core/sdk/src/testing.ts | 15 +- .../core/sdk/src/testing/oauth-test-server.ts | 5 +- 31 files changed, 159 insertions(+), 1073 deletions(-) delete mode 100644 apps/cloud/src/auth/admin-mfa-proof.test.ts delete mode 100644 apps/cloud/src/auth/oauth-admin-verification.test.ts delete mode 100644 e2e/cloud/admin-mfa-api.test.ts delete mode 100644 e2e/cloud/admin-mfa.test.ts delete mode 100644 e2e/cloud/support/admin-mfa.ts 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 1c80219263..48db9c2df3 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -59,7 +59,6 @@ const session = (accountId: string) => ({ name: null, avatarUrl: null, organizationId: ORG, - adminVerified: true, sealedSession: "sealed", refreshedSession: null, }); diff --git a/apps/cloud/src/admin/admin-users-api.node.test.ts b/apps/cloud/src/admin/admin-users-api.node.test.ts index 844839d24e..68f796bea3 100644 --- a/apps/cloud/src/admin/admin-users-api.node.test.ts +++ b/apps/cloud/src/admin/admin-users-api.node.test.ts @@ -135,7 +135,7 @@ const stubMirror = Layer.succeed( // Only session authentication is served; membership is read from the mirror, // so any other WorkOS call fails the test. -const stubWorkOS = (userId: string, adminVerified: boolean) => +const stubWorkOS = (userId: string) => Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { @@ -144,7 +144,6 @@ const stubWorkOS = (userId: string, adminVerified: boolean) => return () => Effect.succeed({ userId, - adminVerified, email: `${userId}@placeholder.test`, organizationId: null, }); @@ -154,37 +153,18 @@ const stubWorkOS = (userId: string, adminVerified: boolean) => }), ); -const authorizeAs = (userId: string, adminVerified = true, authorization?: string) => +const authorizeAs = (userId: string) => authorizeTenant( new Request("https://admin.invalid", { - headers: { - cookie: "wos-session=sealed", - [ORG_SELECTOR_HEADER]: ORG, - ...(authorization === undefined ? {} : { authorization }), - }, + headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG }, }), ).pipe( Effect.provide( - Layer.mergeAll( - stubDirectory, - stubApiKeys, - stubUsers, - stubWorkOS(userId, adminVerified), - stubMirror, - ), + Layer.mergeAll(stubDirectory, stubApiKeys, stubUsers, stubWorkOS(userId), stubMirror), ), ); describe("authorizeTenant · admin session", () => { - it.effect("a bearer header cannot borrow a verified browser's cross-user access", () => - Effect.gen(function* () { - for (const authorization of ["Bearer org_key", "Bearer user_key", "Bearer", "invalid"]) { - expect(yield* Effect.flip(authorizeAs("user_admin", true, authorization))).toBeInstanceOf( - AdminUsersForbidden, - ); - } - }), - ); it.effect("an active admin resolves the selected org as the tenant", () => Effect.gen(function* () { const tenant = yield* authorizeAs("user_admin"); @@ -192,14 +172,6 @@ describe("authorizeTenant · admin session", () => { }), ); - it.effect("an admin without a second factor is forbidden", () => - Effect.gen(function* () { - expect(yield* Effect.flip(authorizeAs("user_admin", false))).toBeInstanceOf( - AdminUsersForbidden, - ); - }), - ); - it.effect("an active plain member is forbidden", () => Effect.gen(function* () { const error = yield* Effect.flip(authorizeAs("user_member")); diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index 3167527041..aac518b0f6 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -67,7 +67,7 @@ const stubDirectory = Layer.succeed(MemberDirectory)({ email: null, name: null, avatarUrl: null, - role: "admin", + role: "member", status: "active" as const, lastActiveAt: null, } diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/apps/cloud/src/api/protected-jwt-auth.node.test.ts index 367f1aee29..edf2a0796c 100644 --- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -84,7 +84,7 @@ const stubDirectory = Layer.succeed(MemberDirectory)({ email: null, name: null, avatarUrl: null, - role: "admin", + role: "member", status: "active" as const, lastActiveAt: null, } diff --git a/apps/cloud/src/auth/admin-mfa-proof.test.ts b/apps/cloud/src/auth/admin-mfa-proof.test.ts deleted file mode 100644 index 77adc7538f..0000000000 --- a/apps/cloud/src/auth/admin-mfa-proof.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { SignJWT } from "jose"; -import { readAdminMfaProof, signAdminMfaProof } from "./admin-mfa-proof"; - -const secret = "a-test-only-cookie-password-of-32-characters"; -const identity = { userId: "user_test", sessionId: "session_test" }; -const now = 1_800_000_000_000; -const proof = { - mode: "challenge" as const, - factorId: "factor_test", - challengeId: "challenge_test", - exp: now / 1000 + 900, -}; -const signed = signAdminMfaProof(secret, identity, "verified", proof, now); - -describe("admin verification cookie", () => { - it.effect("accepts a valid proof for the same user and session", () => - Effect.gen(function* () { - const token = yield* signed; - expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toEqual(proof); - }), - ); - - it.effect("refuses missing, modified, and unsigned cookies", () => - Effect.gen(function* () { - const token = yield* signed; - const parts = token.split("."); - const unsigned = `${btoa('{"alg":"none"}')}.${parts[1]}.`; - for (const value of [ - undefined, - "", - "bad.cookie", - `${token.slice(0, 50)}x${token.slice(51)}`, - unsigned, - ]) { - expect(yield* readAdminMfaProof(secret, identity, "verified", value, now)).toBeNull(); - } - }), - ); - - it.effect("refuses another session, another user, and another signing key", () => - Effect.gen(function* () { - const token = yield* signed; - for (const other of [ - { ...identity, userId: "other" }, - { ...identity, sessionId: "other" }, - ]) { - expect(yield* readAdminMfaProof(secret, other, "verified", token, now)).toBeNull(); - } - expect( - yield* readAdminMfaProof(`${secret}-rotated`, identity, "verified", token, now), - ).toBeNull(); - }), - ); - - it.effect("cannot promote an unfinished challenge to verified access", () => - Effect.gen(function* () { - const token = yield* signAdminMfaProof( - secret, - identity, - "challenge", - { ...proof, mode: "enroll", exp: now / 1000 + 300 }, - now, - ); - expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); - expect( - yield* readAdminMfaProof(secret, identity, "challenge", token, now + 299_000), - ).not.toBeNull(); - expect( - yield* readAdminMfaProof(secret, identity, "challenge", token, now + 300_000), - ).toBeNull(); - }), - ); - - it.effect("expires at fifteen minutes and refuses a future-issued cookie", () => - Effect.gen(function* () { - const token = yield* signed; - expect( - yield* readAdminMfaProof(secret, identity, "verified", token, now + 899_000), - ).not.toBeNull(); - expect( - yield* readAdminMfaProof(secret, identity, "verified", token, now + 900_000), - ).toBeNull(); - expect( - yield* readAdminMfaProof(secret, identity, "verified", token, now - 10_000), - ).toBeNull(); - }), - ); - - it.effect("caps token age even when the supplied expiration is longer", () => - Effect.gen(function* () { - const token = yield* signAdminMfaProof( - secret, - identity, - "verified", - { ...proof, exp: now / 1000 + 86400 }, - now, - ); - expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); - expect( - yield* readAdminMfaProof(secret, identity, "verified", token, now + 901_000), - ).toBeNull(); - }), - ); - - it.effect("rejects a signed cookie with missing issued-at or another algorithm", () => - Effect.gen(function* () { - for (const algorithm of ["HS256", "HS384"]) { - const jwt = new SignJWT({ ...proof }) - .setProtectedHeader({ alg: algorithm }) - .setIssuer("executor:admin-mfa:verified") - .setSubject(identity.userId) - .setAudience(identity.sessionId); - // HS256 lacks iat; HS384 is otherwise valid but outside the allowlist. - if (algorithm === "HS384") jwt.setIssuedAt(now / 1000); - const token = yield* Effect.promise(() => jwt.sign(new TextEncoder().encode(secret))); - expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); - } - }), - ); -}); diff --git a/apps/cloud/src/auth/mirror-feeders.node.test.ts b/apps/cloud/src/auth/mirror-feeders.node.test.ts index 10443449aa..b1cb9ba1de 100644 --- a/apps/cloud/src/auth/mirror-feeders.node.test.ts +++ b/apps/cloud/src/auth/mirror-feeders.node.test.ts @@ -228,7 +228,6 @@ describe("login callback", () => { organizationId: undefined, accessToken: "access", refreshToken: "refresh", - adminVerified: true, sealedSession: "sealed", }), listUserMemberships: (id) => { @@ -594,7 +593,6 @@ describe("session handlers read membership from the mirror", () => { ...options.workos, authenticateSealedSession: () => Effect.succeed({ - adminVerified: true, userId, email: `${userId}@placeholder.test`, organizationId: null, @@ -1101,7 +1099,6 @@ describe("account service writes through to the mirror", () => { name: null, avatarUrl: null, organizationId: null, - adminVerified: true, sealedSession: "sealed", refreshedSession: null, }); diff --git a/apps/cloud/src/auth/oauth-admin-verification.test.ts b/apps/cloud/src/auth/oauth-admin-verification.test.ts deleted file mode 100644 index 91571851a3..0000000000 --- a/apps/cloud/src/auth/oauth-admin-verification.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { encodeOAuthCallbackState } from "@executor-js/sdk/shared"; -import { oauthAdminVerificationResponse } from "./oauth-admin-verification"; - -describe("OAuth admin verification recovery", () => { - it("keeps provider credentials out of the recovery page and preserves session cookies", async () => { - const state = encodeOAuthCallbackState({ state: "private-state", orgSlug: "example-org" }); - const request = new Request( - `https://app.example/api/oauth/callback?code=private-code&state=${state}`, - ); - const denied = Response.json( - { code: "admin_mfa_required" }, - { - status: 403, - headers: { - "set-cookie": "wos-session=rotated; Secure; HttpOnly", - "x-executor-error": "org_write_denied", - }, - }, - ); - const response = await oauthAdminVerificationResponse(request, denied); - expect(response.status).toBe(200); - expect(response.headers.get("set-cookie")).toContain("wos-session=rotated"); - expect(response.headers.get("cache-control")).toBe("no-store"); - expect(response.headers.get("referrer-policy")).toBe("no-referrer"); - expect(response.headers.get("content-security-policy")).toContain("default-src 'none'"); - const body = await response.text(); - expect(body).toContain('href="/example-org/org"'); - expect(body).toContain("Continue connection"); - expect(body).not.toContain("private-code"); - expect(body).not.toContain(state); - expect(body).not.toContain(" { - const request = new Request("https://app.example/api/oauth/callback?state=invalid"); - const denied = Response.json({ code: "no_organization" }, { status: 403 }); - expect(await oauthAdminVerificationResponse(request, denied)).toBe(denied); - }); -}); diff --git a/apps/cloud/src/auth/org-selector-auth.node.test.ts b/apps/cloud/src/auth/org-selector-auth.node.test.ts index f652213c2c..f972a2e757 100644 --- a/apps/cloud/src/auth/org-selector-auth.node.test.ts +++ b/apps/cloud/src/auth/org-selector-auth.node.test.ts @@ -99,7 +99,6 @@ const stubWorkOS = Layer.succeed( userId: MEMBER, email: "u@e2e.test", organizationId: SESSION_ORG, - adminVerified: true, }); } // Membership is read from the mirror, never from WorkOS: any WorkOS diff --git a/apps/cloud/src/auth/workos.node.test.ts b/apps/cloud/src/auth/workos.node.test.ts index cadeb1078b..ca81b7717a 100644 --- a/apps/cloud/src/auth/workos.node.test.ts +++ b/apps/cloud/src/auth/workos.node.test.ts @@ -1,7 +1,6 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; -import { signAdminMfaProof } from "./admin-mfa-proof"; import { describe, expect, it } from "@effect/vitest"; import { env } from "cloudflare:workers"; import { Effect, Schema } from "effect"; @@ -175,7 +174,7 @@ const withWorkOSStub = async ( }); }; -const runAuthenticate = (sessionData: string, baseUrl: string, proof?: string) => { +const runAuthenticate = (sessionData: string, baseUrl: string) => { Object.assign(env, { WORKOS_API_KEY: API_KEY, WORKOS_CLIENT_ID: CLIENT_ID, @@ -186,54 +185,12 @@ const runAuthenticate = (sessionData: string, baseUrl: string, proof?: string) = return Effect.runPromise( Effect.gen(function* () { const workos = yield* WorkOSClient; - return yield* workos.authenticateSealedSession(sessionData, proof); + return yield* workos.authenticateSealedSession(sessionData); }).pipe(Effect.provide(WorkOSClient.Default)), ); }; describe("authenticateSealedSession", () => { - it("binds admin verification to the authenticated session and refuses expired proofs", async () => { - const keypair = await generateKeypair("k_admin_mfa"); - await withWorkOSStub(keypair, async (stub) => { - const session = await sealSession( - await signAccessToken(keypair, { sessionId: "session_admin" }), - ); - const now = Date.now(); - const makeProof = (sessionId: string, timestamp: number) => - Effect.runPromise( - signAdminMfaProof( - COOKIE_PASSWORD, - { userId: USER.id, sessionId }, - "verified", - { - factorId: "factor_test", - challengeId: "challenge_test", - mode: "challenge", - exp: Math.floor(timestamp / 1000) + 900, - }, - timestamp, - ), - ); - expect( - (await runAuthenticate(session, stub.baseUrl, await makeProof("session_admin", now))) - ?.adminVerified, - ).toBe(true); - expect( - (await runAuthenticate(session, stub.baseUrl, await makeProof("session_other", now))) - ?.adminVerified, - ).toBe(false); - expect( - ( - await runAuthenticate( - session, - stub.baseUrl, - await makeProof("session_admin", now - 901_000), - ) - )?.adminVerified, - ).toBe(false); - }); - }); - it("validates a sealed session locally with the cached JWKS", async () => { const keypair = await generateKeypair("k_valid"); await withWorkOSStub(keypair, async (stub) => { @@ -254,8 +211,6 @@ describe("authenticateSealedSession", () => { organizationId: "org_test", sessionId: "session_valid", refreshedSession: undefined, - adminVerified: false, - adminVerificationExpiresAt: null, }); expect(stub.requests()).toEqual([ { method: "GET", path: `/sso/jwks/${CLIENT_ID}`, body: null }, diff --git a/apps/cloud/src/mcp/auth-provider.test.ts b/apps/cloud/src/mcp/auth-provider.test.ts index c4a580841b..1a88793d69 100644 --- a/apps/cloud/src/mcp/auth-provider.test.ts +++ b/apps/cloud/src/mcp/auth-provider.test.ts @@ -165,9 +165,7 @@ describe("cloud MCP org-authorization classification", () => { expect(principal?.accountId).toBe(ACCOUNT_ID); expect(principal?.organizationId).toBe(ORG_ID); expect(principal?.orgRoleModel).toBe("organization"); - expect(principal?.orgRole, "machine tokens never grant workspace administration").toBe( - "member", - ); + expect(principal?.orgRole, "the live membership role reaches the MCP session").toBe("admin"); const legacyAccess = principal ? orgWriteAccessForPrincipal( (({ orgRole: _orgRole, ...legacyMissingRole }) => legacyMissingRole)(principal), diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index 4a564d7a32..1cae2aa9a2 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -74,7 +74,7 @@ const provide = ( ): Layer.Layer => Layer.mergeAll( Layer.succeed(AuthContext)(adminAuth), - Layer.succeed(OrgMemberRole)({ memberRole, adminVerified: true }), + Layer.succeed(OrgMemberRole)({ memberRole }), stubWorkOS(workosOverrides), ); @@ -84,12 +84,6 @@ describe("Org domain handlers", () => { requireAdmin.pipe(Effect.provide(provide("admin"))), ); - it.effect("rejects an admin without a verified second factor", () => - Effect.gen(function* () { - expect(yield* Effect.flip(requireAdmin)).toBeInstanceOf(Forbidden); - }).pipe(Effect.provideService(OrgMemberRole, { memberRole: "admin", adminVerified: false })), - ); - it.effect("rejects a non-admin caller with Forbidden", () => Effect.gen(function* () { const error = yield* Effect.flip(requireAdmin); @@ -245,7 +239,6 @@ const workosForCaller = (deleted: string[]) => authenticateSealedSession: () => Effect.succeed({ userId: CALLER, - adminVerified: true, email: "caller@placeholder.test", organizationId: ORG, }), diff --git a/bun.lock b/bun.lock index 190814528d..87589fabdd 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.3-mfa.0", + "@executor-js/emulate": "^0.14.2", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -381,7 +381,6 @@ "@vitejs/plugin-react": "catalog:", "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", - "otpauth": "9.5.2", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", @@ -1812,7 +1811,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@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/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/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], @@ -2222,7 +2221,7 @@ "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], - "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], "@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=="], @@ -3522,8 +3521,6 @@ "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=="], @@ -3768,8 +3765,6 @@ "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=="], @@ -3824,8 +3819,6 @@ "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=="], @@ -4882,8 +4875,6 @@ "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=="], @@ -5004,7 +4995,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@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -5086,8 +5077,6 @@ "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=="], @@ -5266,8 +5255,6 @@ "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=="], @@ -5354,8 +5341,6 @@ "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=="], @@ -5770,8 +5755,6 @@ "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=="], @@ -5888,8 +5871,6 @@ "@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=="], @@ -5980,8 +5961,6 @@ "@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=="], @@ -5990,8 +5969,6 @@ "@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=="], @@ -6026,8 +6003,6 @@ "@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=="], @@ -6238,8 +6213,6 @@ "@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=="], @@ -6502,8 +6475,6 @@ "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=="], @@ -6770,8 +6741,6 @@ "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=="], @@ -7140,10 +7109,6 @@ "@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=="], @@ -7792,14 +7757,6 @@ "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=="], @@ -8048,10 +8005,6 @@ "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=="], @@ -8080,8 +8033,6 @@ "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 deleted file mode 100644 index 449376014f..0000000000 --- a/e2e/cloud/admin-mfa-api.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -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 { verifyAdmin, responseCookies } from "./support/admin-mfa"; - -const decodeKey = Schema.decodeUnknownSync( - Schema.Struct({ id: Schema.String, value: Schema.String }), -); - -const decodeSetup = Schema.decodeUnknownOption( - Schema.Struct({ kind: Schema.Literal("enroll"), secret: Schema.String }), -); - -scenario( - "Admin MFA API · requires same-origin requests and binds verification to the session", - {}, - Effect.gen(function* () { - const target = yield* Target; - const identity = yield* target.newIdentity({ adminMfa: false }); - const other = yield* target.newIdentity({ adminMfa: false }); - yield* Effect.promise(async () => { - const original = identity.headers?.cookie ?? ""; - const headers = { ...identity.headers, "content-type": "application/json" }; - const unverifiedWorkspace = await fetch(new URL("/api/policies", target.baseUrl), { - headers, - }); - expect(unverifiedWorkspace.status).toBe(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 verifiedCookies = responseCookies(pending, verified); - const status = await fetch(new URL("/api/auth/admin-mfa", target.baseUrl), { - headers: { ...headers, cookie: verifiedCookies }, - }); - expect(await status.json()).toMatchObject({ state: "verified" }); - expect( - (await post("verify", pending, new TOTP({ secret: setup.secret }).generate())).status, - ).toBe(400); - - // A later verification uses the existing factor and never returns its secret. - const repeat = await post("start", original); - expect(await repeat.json()).toEqual({ kind: "challenge" }); - const repeated = await post( - "verify", - responseCookies(original, repeat), - new TOTP({ secret: setup.secret }).generate(), - ); - expect(repeated.status).toBe(200); - }); - }), -); - -scenario( - "Admin MFA API · restarting enrollment cannot reset the rate limit", - {}, - Effect.gen(function* () { - const target = yield* Target; - const identity = yield* target.newIdentity({ adminMfa: false }); - yield* Effect.promise(async () => { - const statuses: number[] = []; - for (let attempt = 0; attempt < 6; attempt++) { - const response = await fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { - method: "POST", - headers: { - ...identity.headers, - origin: new URL(target.baseUrl).origin, - "content-type": "application/json", - }, - body: "{}", - }); - statuses.push(response.status); - await response.text(); - } - expect(statuses).toEqual([200, 200, 200, 200, 200, 429]); - }); - }), -); - -scenario( - "Admin MFA API · locks administration without blocking personal API access", - {}, - Effect.gen(function* () { - const target = yield* Target; - const locked = yield* target.newIdentity({ adminMfa: false }); - 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) }), - }); - const adminReads = ["/api/account/org-api-keys", "/api/admin/users", "/api/org/domains"]; - for (const path of adminReads) { - expect((await send(locked.headers, "GET", path)).status, path).toBe(403); - expect((await send(unlocked.headers, "GET", path)).status, path).toBe(200); - } - for (const [method, path, body] of [ - ["PATCH", "/api/account/name", { name: "Unauthorized rename" }], - ["POST", "/api/auth/delete-organization", { confirmName: "Unauthorized deletion" }], - ["DELETE", "/api/account/members/om_unknown", undefined], - ["PATCH", "/api/account/members/om_unknown/role", { roleSlug: "admin" }], - [ - "POST", - "/api/account/members/invite", - { email: "blocked@example.test", roleSlug: "admin" }, - ], - ["POST", "/api/account/org-api-keys", { name: "blocked" }], - ["POST", "/api/billing/openCustomerPortal", {}], - ["POST", "/api/billing/attach", {}], - ["POST", "/api/org/domains/verify-link", {}], - ["POST", "/api/policies", { owner: "org", pattern: "blocked.*", action: "approve" }], - ] as const) { - expect((await send(locked.headers, method, path, body)).status, path).toBe(403); - } - const renamed = await send(unlocked.headers, "PATCH", "/api/account/name", { - name: "Verified test workspace", - }); - expect(renamed.status).toBe(200); - const keyResponse = await send(locked.headers, "POST", "/api/account/api-keys", { - name: "Personal access", - }); - const key = decodeKey(await keyResponse.json()); - try { - const bearer = { authorization: `Bearer ${key.value}` }; - expect((await send(bearer, "GET", "/api/integrations")).status).toBe(200); - expect( - ( - await send(bearer, "POST", "/api/policies", { - owner: "org", - pattern: "blocked.*", - action: "approve", - }) - ).status, - ).toBe(403); - expect( - ( - await send({ ...unlocked.headers, ...bearer }, "POST", "/api/policies", { - owner: "org", - pattern: "blocked.*", - action: "approve", - }) - ).status, - ).toBe(403); - expect( - (await send({ ...unlocked.headers, ...bearer }, "GET", "/api/admin/users")).status, - ).toBe(403); - } finally { - expect( - (await send(locked.headers, "DELETE", `/api/account/api-keys/${key.id}`)).status, - ).toBe(200); - } - const lock = await send(unlocked.headers, "POST", "/api/auth/admin-mfa/lock", {}); - expect(lock.status).toBe(200); - const headers = { - ...unlocked.headers, - cookie: responseCookies(unlocked.headers?.cookie ?? "", lock), - }; - for (const path of adminReads) - expect((await send(headers, "GET", path)).status, path).toBe(403); - expect((await send(headers, "GET", "/api/integrations")).status).toBe(200); - expect((await send(headers, "GET", "/api/account/api-keys")).status).toBe(200); - }); - }), -); diff --git a/e2e/cloud/admin-mfa.test.ts b/e2e/cloud/admin-mfa.test.ts deleted file mode 100644 index dcc1c24861..0000000000 --- a/e2e/cloud/admin-mfa.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { expect } from "@effect/vitest"; -import { Effect, Option, Schema } from "effect"; -import { TOTP } from "otpauth"; -import { scenario } from "../src/scenario"; -import { Browser, Target } from "../src/services"; -import { joinOrg, orgSelectorOf } from "./support/session"; - -const Setup = Schema.Struct({ kind: Schema.Literal("enroll"), secret: Schema.String }); -const decodeSetup = Schema.decodeUnknownOption(Setup); -const proofName = "__Host-executor-admin-mfa"; - -scenario( - "Admin MFA · enroll, cancel, retry, and verify before opening admin settings", - { timeout: 120_000 }, - Effect.gen(function* () { - const target = yield* Target; - const browser = yield* Browser; - const identity = yield* target.newIdentity({ adminMfa: false }); - const path = `/${orgSelectorOf(identity)}/org`; - yield* browser.session(identity, async ({ page, step }) => { - await step("Use the workspace and personal keys without enrolling", async () => { - await page.goto(`/${orgSelectorOf(identity)}/api-keys`); - await page.getByRole("heading", { name: "Personal keys", exact: true }).waitFor(); - await page.getByRole("heading", { name: "Unlock administration" }).waitFor(); - expect(await page.getByRole("button", { name: "New org key" }).count()).toBe(0); - await page.getByRole("link", { name: "Back to workspace" }).click(); - await page.waitForURL((url) => url.pathname === `/${orgSelectorOf(identity)}`); - await page - .getByRole("heading", { name: "Unlock administration" }) - .waitFor({ state: "detached" }); - await page.getByRole("link", { name: "Unlock administration", exact: true }).waitFor(); - await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor(); - expect(await page.getByRole("heading", { name: "Unlock administration" }).count()).toBe(0); - }); - await step("Open organization settings without a second factor", async () => { - await page.goto(path); - await page.getByRole("heading", { name: "Unlock administration" }).waitFor(); - const denied = await page.request.get("/api/admin/users", { headers: identity.headers }); - expect(denied.status()).toBe(403); - const keyDenied = await page.request.get("/api/account/org-api-keys", { - headers: identity.headers, - }); - expect(keyDenied.status()).toBe(403); - }); - await step("Start setup and cancel it", async () => { - await page.getByRole("button", { name: "Continue", exact: true }).click(); - await page.getByAltText("Authenticator setup QR code").waitFor(); - await page.getByRole("button", { name: "Cancel", exact: true }).click(); - await page.getByRole("button", { name: "Continue", exact: true }).waitFor(); - await page.getByRole("link", { name: "Back to workspace" }).click(); - await page.getByRole("link", { name: "Unlock administration", exact: true }).click(); - }); - let secret = ""; - await step("Start setup again and enter an incorrect code", async () => { - const pending = page.waitForResponse((response) => - response.url().endsWith("/api/auth/admin-mfa/start"), - ); - await page.getByRole("button", { name: "Continue", exact: true }).click(); - const setup = Option.getOrNull(decodeSetup(await (await pending).json())); - expect(setup).not.toBeNull(); - if (!setup) throw new Error("MFA setup did not return a secret"); - secret = setup.secret; - const oldCode = new TOTP({ secret }).generate({ timestamp: Date.now() - 600_000 }); - await page.getByLabel("Six-digit code").fill(oldCode); - await page.getByRole("button", { name: "Verify", exact: true }).click(); - await page.getByRole("alert").filter({ hasText: "That code did not work" }).waitFor(); - expect((await page.context().cookies()).some((cookie) => cookie.name === proofName)).toBe( - false, - ); - }); - await step("Enter the current code and open admin settings", async () => { - await page.getByLabel("Six-digit code").fill(new TOTP({ secret }).generate()); - await page.getByRole("button", { name: "Verify", exact: true }).click(); - await page.getByRole("button", { name: "Add domain", exact: true }).waitFor(); - expect(new URL(page.url()).pathname).toBe(path); - const proof = (await page.context().cookies()).find((cookie) => cookie.name === proofName); - expect(proof).toMatchObject({ httpOnly: true, secure: true, sameSite: "Lax" }); - const headers = { "x-executor-organization": orgSelectorOf(identity) }; - expect((await page.request.get("/api/admin/users", { headers })).status()).toBe(200); - }); - await step("Lock administration and keep using the workspace", async () => { - await page.goto(`/${orgSelectorOf(identity)}`); - await page.getByRole("button", { name: "Lock administration", exact: true }).click(); - await page.getByRole("link", { name: "Unlock administration", exact: true }).waitFor(); - await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor(); - const headers = { "x-executor-organization": orgSelectorOf(identity) }; - expect((await page.request.get("/api/admin/users", { headers })).status()).toBe(403); - expect((await page.request.get("/api/integrations", { headers })).status()).toBe(200); - }); - }); - }), -); - -scenario( - "Admin MFA · members keep their normal view and cannot enroll as an admin", - {}, - Effect.gen(function* () { - const target = yield* Target; - const browser = yield* Browser; - const admin = yield* target.newIdentity(); - const member = yield* joinOrg(target, admin, yield* target.newIdentity({ org: false })); - const response = yield* Effect.promise(() => - fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { - method: "POST", - headers: { - ...member.headers, - origin: new URL(target.baseUrl).origin, - "content-type": "application/json", - }, - body: "{}", - }), - ); - expect(response.status).toBe(403); - yield* browser.session(member, async ({ page, step }) => { - await step("Open organization settings as a member", async () => { - await page.goto(`/${orgSelectorOf(member)}/org`); - await page.getByRole("heading", { name: "Members", exact: true }).waitFor(); - expect(await page.getByRole("heading", { name: "Unlock administration" }).count()).toBe(0); - }); - }); - }), -); diff --git a/e2e/cloud/auth-hint.test.ts b/e2e/cloud/auth-hint.test.ts index 6d7b8bf634..a04502c851 100644 --- a/e2e/cloud/auth-hint.test.ts +++ b/e2e/cloud/auth-hint.test.ts @@ -112,10 +112,6 @@ scenario( const names = (await page.context().cookies()).map((cookie) => cookie.name); expect(names, "the hint never outlives the session").not.toContain(HINT_COOKIE); expect(names, "the session itself is gone too").not.toContain("wos-session"); - expect(names, "admin verification ends on logout").not.toContain("__Host-executor-admin-mfa"); - expect(names, "unfinished admin challenges are cleared").not.toContain( - "__Host-executor-admin-challenge", - ); }); }), ); diff --git a/e2e/cloud/mcp-browser-resume-page.test.ts b/e2e/cloud/mcp-browser-resume-page.test.ts index 70785e6f7e..5e82e5eb0d 100644 --- a/e2e/cloud/mcp-browser-resume-page.test.ts +++ b/e2e/cloud/mcp-browser-resume-page.test.ts @@ -17,7 +17,6 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { scenario } from "../src/scenario"; import { Api, Browser, Mcp, Target } from "../src/services"; import { parseBrowserApproval } from "../src/surfaces/mcp"; -import { verifyAdmin } from "./support/admin-mfa"; import type { Identity } from "../src/target"; const coreApi = composePluginApi([] as const); @@ -275,77 +274,3 @@ scenario( ); }), ); - -scenario( - "MCP approval · an admin without MFA cannot grant workspace writes on resume", - { timeout: 180_000 }, - Effect.gen(function* () { - const target = yield* Target; - const { client: apiClient } = yield* Api; - const mcp = yield* Mcp; - const locked = yield* target.newIdentity({ adminMfa: false }); - const unlocked = yield* verifyAdmin(target.baseUrl, locked); - const api = yield* apiClient(coreApi, unlocked); - const policy = yield* api.policies.create({ - payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" }, - }); - yield* Effect.gen(function* () { - const bearer = yield* mcp.mintBearer(emailOf(locked)); - const session = yield* Effect.promise(() => - openBrowserApprovalSession(target.mcpUrl, bearer), - ); - yield* Effect.gen(function* () { - const paused = yield* Effect.promise(() => - session.client.callTool({ - name: "execute", - arguments: { - code: `${GATED_CODE.replace("return JSON.stringify(result);", "")} - return await tools.executor.coreTools.policies.create({ owner: "org", pattern: "resume-bypass.*", action: "approve" });`, - }, - }), - ); - let resumed = paused; - // The policy read and the subsequent policy write each need approval. - // Both browser decisions must preserve the locked session's authority. - for (const _step of ["read", "write"]) { - const approval = parseBrowserApproval({ - raw: resumed, - text: textOf(resumed), - ok: resumed.isError !== true, - }); - const resumeUrl = new URL( - `/api/mcp-sessions/${encodeURIComponent(session.transport.sessionId ?? "")}/executions/${encodeURIComponent(approval.executionId)}/resume`, - target.baseUrl, - ); - const approved = yield* Effect.promise(() => - authenticatedFetch(locked, resumeUrl, { - method: "POST", - headers: { - "content-type": "application/json", - origin: new URL(target.baseUrl).origin, - }, - body: JSON.stringify({ action: "accept", content: {} }), - }), - ); - expect(approved.status).toBe(200); - resumed = yield* Effect.promise(() => - session.client.callTool({ - name: "resume", - arguments: { executionId: approval.executionId }, - }), - ); - } - expect(textOf(resumed)).toMatch(/OrgWriteDenied|administrator|admin/i); - expect((yield* api.policies.list()).some((row) => row.pattern === "resume-bypass.*")).toBe( - false, - ); - }).pipe(Effect.ensuring(closeQuietly(session))); - }).pipe( - Effect.ensuring( - api.policies - .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) - .pipe(Effect.ignore), - ), - ); - }), -); diff --git a/e2e/cloud/mcp-workspace-write-permissions.test.ts b/e2e/cloud/mcp-workspace-write-permissions.test.ts index f24aff3c44..7c13c8c16e 100644 --- a/e2e/cloud/mcp-workspace-write-permissions.test.ts +++ b/e2e/cloud/mcp-workspace-write-permissions.test.ts @@ -20,7 +20,7 @@ return JSON.stringify(created); `; scenario( - "MCP workspace writes · both member and admin tokens are denied workspace writes", + "MCP workspace writes · a member session is denied while an admin session succeeds", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; @@ -67,14 +67,14 @@ scenario( if (allowed.text.includes("Execution paused")) { allowed = yield* adminSession.approvePaused(allowed.text); } - expect( - allowed.text, - "even an MFA-verified admin's machine token has no workspace write access", - ).toMatch(/OrgWriteDenied|administrator|admin/i); + expect(allowed.ok, "the admin's MCP workspace-write call succeeds").toBe(true); + expect(allowed.text, "the created policy is returned over the MCP session").toContain( + pattern, + ); expect( (yield* adminClient.policies.list()).some((policy) => policy.pattern === pattern), - "the admin's denied MCP call persisted no Workspace policy", - ).toBe(false); + "the admin's MCP call persisted the Workspace policy", + ).toBe(true); }), cleanup, ); diff --git a/e2e/cloud/oauth-callback-unauthenticated.test.ts b/e2e/cloud/oauth-callback-unauthenticated.test.ts index 99797915c6..e4e4df56bf 100644 --- a/e2e/cloud/oauth-callback-unauthenticated.test.ts +++ b/e2e/cloud/oauth-callback-unauthenticated.test.ts @@ -14,7 +14,6 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; -import { verifyAdminInBrowser } from "./support/admin-mfa"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -53,123 +52,101 @@ const oauthIntegrationSpec = (oauth: { ], }) as const; -for (const owner of ["org", "user"] as const) - scenario( - `OAuth callback · a signed-out ${owner} callback uses login returnTo and resumes the connection`, - {}, - Effect.gen(function* () { - const target = yield* Target; - const { client: makeApiClient } = yield* Api; - const browser = yield* Browser; - const oauth = yield* serveOAuthTestServer(); - const identity = yield* target.newIdentity(); - const client = yield* makeApiClient(api, identity); - - const integration = IntegrationSlug.make(unique("signedoutcb")); - yield* client.openapi.addSpec({ - payload: { ...oauthIntegrationSpec(oauth), slug: integration }, - }); - - const clientSlug = OAuthClientSlug.make(unique("signedoutc")); - yield* client.oauth.createClient({ - payload: { - owner: "org", - slug: clientSlug, - authorizationUrl: oauth.authorizationEndpoint, - tokenUrl: oauth.tokenEndpoint, - grant: "authorization_code", - clientId: "test-client", - clientSecret: "test-secret", +scenario( + "OAuth callback · a signed-out callback uses login returnTo and resumes the connection", + {}, + Effect.gen(function* () { + const target = yield* Target; + const { client: makeApiClient } = yield* Api; + const browser = yield* Browser; + const oauth = yield* serveOAuthTestServer(); + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + + const integration = IntegrationSlug.make(unique("signedoutcb")); + yield* client.openapi.addSpec({ + payload: { ...oauthIntegrationSpec(oauth), slug: integration }, + }); + + const clientSlug = OAuthClientSlug.make(unique("signedoutc")); + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: clientSlug, + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }, + }); + + const started = yield* client.oauth.start({ + payload: { + client: clientSlug, + clientOwner: "org", + owner: "org", + name: ConnectionName.make("main"), + integration, + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(started.status, "oauth.start begins at the provider").toBe("redirect"); + const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : ""; + + const authorize = yield* Effect.promise(() => fetch(authorizationUrl, { redirect: "manual" })); + expect(authorize.status, "the provider asks the user to log in").toBe(302); + const consent = yield* Effect.promise(() => + fetch(authorize.headers.get("location") ?? "", { + method: "POST", + redirect: "manual", + headers: { + authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, }, + }), + ); + expect(consent.status, "provider consent redirects back to Executor").toBe(302); + const callback = new URL(consent.headers.get("location") ?? ""); + const callbackPath = `${callback.pathname}${callback.search}`; + + // No cookies: this mirrors a provider redirect reaching the callback after + // the user's web session is gone, expired, or otherwise missing an org. + const anonymous = { label: "anonymous" }; + + yield* browser.session(anonymous, async ({ page, step }) => { + await step("Provider sends a signed-out browser to the OAuth callback", async () => { + const response = await page.goto(callbackPath, { waitUntil: "commit" }); + expect(response?.status(), "the callback redirects into the login flow").toBe(200); + await page.getByText("Sign in to manage your tools and integrations").waitFor(); }); - const started = yield* client.oauth.start({ - payload: { - client: clientSlug, - clientOwner: "org", - owner, - name: ConnectionName.make("main"), - integration, - template: AuthTemplateSlug.make("oauth"), - }, + const loginUrl = new URL(page.url()); + expect(loginUrl.pathname, "the signed-out callback lands on the sign-in page").toBe("/login"); + expect( + loginUrl.searchParams.get("returnTo"), + "login preserves the callback so it can resume after sign-in", + ).toBe(callbackPath); + + await step("Sign in resumes the original OAuth callback", async () => { + await page.getByRole("link", { name: "Sign in" }).click(); + await page.getByPlaceholder("new-user@example.com").fill(identity.credentials!.email); + await page.getByRole("button", { name: /Continue/ }).click(); + await page.waitForURL((url) => url.pathname === "/api/oauth/callback", { + timeout: 30_000, + }); + await page.waitForFunction(() => document.body.innerText.includes("Connected"), null, { + timeout: 30_000, + }); }); - expect(started.status, "oauth.start begins at the provider").toBe("redirect"); - const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : ""; - const authorize = yield* Effect.promise(() => - fetch(authorizationUrl, { redirect: "manual" }), + const body = (await page.locator("body").textContent())?.trim() ?? ""; + expect(new URL(page.url()).pathname, "the login returnTo lands back on the callback").toBe( + "/api/oauth/callback", ); - expect(authorize.status, "the provider asks the user to log in").toBe(302); - const consent = yield* Effect.promise(() => - fetch(authorize.headers.get("location") ?? "", { - method: "POST", - redirect: "manual", - headers: { - authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, - }, - }), + expect(body, "the callback completes after the sign-in recovery").toContain("Connected"); + expect(body, "the raw protected API JSON is not shown").not.toContain( + '"code":"no_organization"', ); - expect(consent.status, "provider consent redirects back to Executor").toBe(302); - const callback = new URL(consent.headers.get("location") ?? ""); - const callbackPath = `${callback.pathname}${callback.search}`; - - // No cookies: this mirrors a provider redirect reaching the callback after - // the user's web session is gone, expired, or otherwise missing an org. - const anonymous = { label: "anonymous" }; - - yield* browser.session(anonymous, async ({ page, step }) => { - await step("Provider sends a signed-out browser to the OAuth callback", async () => { - const response = await page.goto(callbackPath, { waitUntil: "commit" }); - expect(response?.status(), "the callback redirects into the login flow").toBe(200); - await page.getByText("Sign in to manage your tools and integrations").waitFor(); - }); - - const loginUrl = new URL(page.url()); - expect(loginUrl.pathname, "the signed-out callback lands on the sign-in page").toBe( - "/login", - ); - expect( - loginUrl.searchParams.get("returnTo"), - "login preserves the callback so it can resume after sign-in", - ).toBe(callbackPath); - - await step("Sign in resumes the original OAuth callback", async () => { - await page.getByRole("link", { name: "Sign in" }).click(); - await page.getByPlaceholder("new-user@example.com").fill(identity.credentials!.email); - await page.getByRole("button", { name: /Continue/ }).click(); - await page.waitForURL((url) => url.pathname === "/api/oauth/callback", { - timeout: 30_000, - }); - if (owner === "org") { - await page.getByRole("heading", { name: "Unlock administration" }).waitFor(); - } else { - await page.getByText("Connected", { exact: true }).waitFor(); - } - }); - - if (owner === "org") - await step("Unlock administration and finish the pending connection", async () => { - const [verification] = await Promise.all([ - page.context().waitForEvent("page"), - page.getByRole("link", { name: "Unlock administration", exact: true }).click(), - ]); - try { - await verifyAdminInBrowser(verification, identity.credentials?.totpSecret); - } finally { - await verification.close(); - } - await page.getByRole("link", { name: "Continue connection" }).click(); - await page.getByText("Connected", { exact: true }).waitFor(); - }); - - const body = (await page.locator("body").textContent())?.trim() ?? ""; - expect(new URL(page.url()).pathname, "the login returnTo lands back on the callback").toBe( - "/api/oauth/callback", - ); - expect(body, "the callback completes after the sign-in recovery").toContain("Connected"); - expect(body, "the raw protected API JSON is not shown").not.toContain( - '"code":"no_organization"', - ); - }); - }).pipe(Effect.scoped), - ); + }); + }).pipe(Effect.scoped), +); diff --git a/e2e/cloud/org-api-keys-console.test.ts b/e2e/cloud/org-api-keys-console.test.ts index 88e1b0594f..f4b34dafb8 100644 --- a/e2e/cloud/org-api-keys-console.test.ts +++ b/e2e/cloud/org-api-keys-console.test.ts @@ -1,4 +1,19 @@ -// Organization keys retain shared product reads. Cross-user reads require a verified browser session. +// Cloud-only: the Organization keys SECTION of the API keys page — the console +// surface over `/api/account/org-api-keys`, minting the machine credential for +// the tenant-wide admin plane. +// +// Two members are built through the real flows. The guarantees pinned here: +// +// 1. an ADMIN sees the section, mints an org key through the dialog, gets the +// one-time reveal, and the minted value ACTUALLY authenticates the admin +// API (the whole point of the credential); +// 2. the listing shows the key afterward and revoke asks for confirmation +// before killing it — after which the value stops authenticating; +// 3. a PLAIN MEMBER never sees the section at all. +// +// Runs against emulate >= 0.13.9, whose WorkOS emulator serves the org-key +// routes (list/mint via /organizations/:id/api_keys, org-owner validation) — +// the gap that previously kept this scenario impossible. import { expect } from "@effect/vitest"; import { Effect } from "effect"; @@ -14,7 +29,7 @@ declare global { } scenario( - "Admin · organization keys are minted in the console and authenticate shared product reads", + "Admin · organization keys are minted in the console and authenticate the admin API", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; @@ -68,14 +83,20 @@ scenario( .waitFor({ state: "visible", timeout: 30_000 }); }); - await step("The minted value reads shared data but cannot read other users", async () => { - const headers = { authorization: `Bearer ${mintedValue}` }; + await step("The minted value authenticates the tenant-wide admin API", async () => { + // The credential's purpose, proven from outside the browser: a backend + // holding ONLY this value can read the admin plane. + const response = await fetch(new URL("/api/admin/users", target.baseUrl), { + headers: { authorization: `Bearer ${mintedValue}` }, + }); + expect(response.status, "the org key reads the admin plane").toBe(200); + const body = (await response.json()) as { + users: ReadonlyArray<{ email: string | null }>; + }; expect( - (await fetch(new URL("/api/integrations", target.baseUrl), { headers })).status, - ).toBe(200); - expect((await fetch(new URL("/api/admin/users", target.baseUrl), { headers })).status).toBe( - 403, - ); + body.users.map((user) => user.email), + "and sees the workspace's members", + ).toContain(admin.credentials?.email); }); await step("Revoke asks for confirmation, then the key stops working", async () => { @@ -92,7 +113,7 @@ scenario( // The dialog closes when revocation starts. Wait for the confirmed // provider mutation before asserting the key no longer authenticates. await page.getByText("Revoked e2e backend reader", { exact: true }).waitFor(); - const after = await fetch(new URL("/api/integrations", target.baseUrl), { + const after = await fetch(new URL("/api/admin/users", target.baseUrl), { headers: { authorization: `Bearer ${mintedValue}` }, }); expect(after.status, "the revoked key is refused").toBe(401); diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts index e68530314f..bb3f9f187c 100644 --- a/e2e/cloud/org-delete.test.ts +++ b/e2e/cloud/org-delete.test.ts @@ -10,7 +10,6 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; import { visit, settle } from "../src/surfaces/browser"; -import { verifyAdminInBrowser } from "./support/admin-mfa"; scenario( "Organizations · an admin deletes the organization from settings", @@ -41,7 +40,6 @@ scenario( await step("Open Organization settings and find the danger zone", async () => { await visit(page, `/${slug}/org`); - await verifyAdminInBrowser(page); // The admin-only danger zone renders (a member would not see it). await page.getByText("Permanently delete this organization").waitFor(); }); diff --git a/e2e/cloud/session-gate.test.ts b/e2e/cloud/session-gate.test.ts index ced114b232..7868c872af 100644 --- a/e2e/cloud/session-gate.test.ts +++ b/e2e/cloud/session-gate.test.ts @@ -13,7 +13,6 @@ import * as Iron from "iron-webcrypto"; import { scenario } from "../src/scenario"; import { Api, Target } from "../src/services"; import { E2E_COOKIE_PASSWORD } from "../targets/cloud"; -import { browserCookies } from "./support/admin-mfa"; /** A signed-out-style document request (what the gate keys on). */ const documentRequest = (url: URL, cookie?: string) => @@ -116,9 +115,7 @@ scenario( // waiting out a real expiry. Same sealing library + password map the WorkOS // SDK uses, so the gate can't tell this seal from one the SDK minted. const withTamperedAccessToken = async (sessionCookie: string): Promise => { - const cookie = browserCookies(sessionCookie).find((entry) => entry.name === "wos-session"); - if (!cookie) throw new Error("Test identity has no WorkOS session"); - const sealed = cookie.value.replace(/~\d$/, ""); + const sealed = sessionCookie.slice("wos-session=".length).replace(/~\d$/, ""); const session = (await Iron.unseal(sealed, { "1": E2E_COOKIE_PASSWORD }, Iron.defaults)) as { accessToken: string; }; diff --git a/e2e/cloud/support/admin-mfa.ts b/e2e/cloud/support/admin-mfa.ts deleted file mode 100644 index 826fec73b6..0000000000 --- a/e2e/cloud/support/admin-mfa.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Effect, Option, Schema } from "effect"; -import { TOTP } from "otpauth"; -import type { Page } from "playwright"; -import type { Identity } from "../../src/target"; - -const Setup = Schema.Struct({ kind: Schema.Literal("enroll"), secret: Schema.String }); -const decodeSetup = Schema.decodeUnknownOption(Setup); -const Challenge = Schema.Struct({ kind: Schema.Literal("challenge") }); -const decodeChallenge = Schema.decodeUnknownOption(Challenge); -const Verified = Schema.Struct({ verified: Schema.Literal(true) }); -const decodeVerified = Schema.decodeUnknownOption(Verified); -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 headers = { - ...identity.headers, - origin: new URL(baseUrl).origin, - "content-type": "application/json", - }; - const started = await fetch(new URL("/api/auth/admin-mfa/start", baseUrl), { - method: "POST", - headers, - body: "{}", - }); - if (!started.ok) throw new Error(`Admin enrollment failed (${started.status})`); - const raw: unknown = await started.json(); - const setup = Option.getOrNull(decodeSetup(raw)); - const secret = - setup?.secret ?? - (Option.isSome(decodeChallenge(raw)) ? identity.credentials?.totpSecret : undefined); - if (!secret) throw new Error("Missing test authenticator"); - const pending = responseCookies(identity.headers?.cookie ?? "", started); - const verified = await fetch(new URL("/api/auth/admin-mfa/verify", baseUrl), { - method: "POST", - headers: { ...headers, cookie: pending }, - body: JSON.stringify({ code: new TOTP({ secret }).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), - ...(identity.credentials - ? { credentials: { ...identity.credentials, totpSecret: secret } } - : {}), - }; - }); - -/** Complete the visible MFA prompt using enrollment or this test identity's authenticator. */ -export const verifyAdminInBrowser = async (page: Page, secret?: string): Promise => { - await page.getByRole("heading", { name: "Unlock administration" }).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 administration" }).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 62ed040c36..cb8b0d6cf7 100644 --- a/e2e/cloud/support/session.ts +++ b/e2e/cloud/support/session.ts @@ -17,7 +17,6 @@ // // `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module // is never collected as a suite. -import { browserCookies } from "./admin-mfa"; import { Effect } from "effect"; import type { Identity, Target as TargetShape } from "../../src/target"; @@ -54,7 +53,7 @@ export const forBrowser = (identity: Identity): Identity => { if (separator < 0) throw new Error("identity carries no session cookie"); return { ...identity, - cookies: browserCookies(cookie), + cookies: [{ name: cookie.slice(0, separator), value: cookie.slice(separator + 1) }], }; }; @@ -99,15 +98,7 @@ export const withRefreshedSession = ( .find((header) => header.startsWith("wos-session=")) ?.split(";")[0]; if (!refreshed) throw new Error("response did not refresh the session cookie"); - const cookies = browserCookies(cookieOf(identity)).filter( - (cookie) => cookie.name !== "wos-session", - ); - const cookie = [refreshed, ...cookies.map(({ name, value }) => `${name}=${value}`)].join("; "); - return { - ...identity, - headers: { cookie, [ORG_SELECTOR_HEADER]: orgSelector }, - cookies: browserCookies(cookie), - }; + return { ...identity, headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector } }; }; /** The org selector this identity's requests carry — the same header the web diff --git a/e2e/cloud/workspace-write-permissions.test.ts b/e2e/cloud/workspace-write-permissions.test.ts index 5579b44741..955e175f3a 100644 --- a/e2e/cloud/workspace-write-permissions.test.ts +++ b/e2e/cloud/workspace-write-permissions.test.ts @@ -3,7 +3,6 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Target } from "../src/services"; import { workspaceWritePermissions } from "../src/workspace-write-permissions"; -import { verifyAdmin } from "./support/admin-mfa"; import { joinOrg } from "./support/session"; scenario( @@ -17,14 +16,3 @@ scenario( yield* workspaceWritePermissions(target, admin, member); }), ); - -scenario( - "Workspace writes · an administrator without MFA keeps Personal access only", - { timeout: 180_000 }, - Effect.gen(function* () { - const target = yield* Target; - const locked = yield* target.newIdentity({ adminMfa: false }); - const unlocked = yield* verifyAdmin(target.baseUrl, locked); - yield* workspaceWritePermissions(target, unlocked, locked); - }), -); diff --git a/e2e/package.json b/e2e/package.json index a1ef204f65..10f9a8e40f 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "0.14.3-mfa.0", + "@executor-js/emulate": "^0.14.2", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -48,7 +48,6 @@ "@vitejs/plugin-react": "catalog:", "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", - "otpauth": "9.5.2", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts index de7275fcf8..6510f77d9a 100644 --- a/e2e/src/surfaces/browser.ts +++ b/e2e/src/surfaces/browser.ts @@ -202,13 +202,10 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface await installRecordingUrlBar(context); if (identity.cookies?.length) { await context.addCookies( - identity.cookies.map((cookie) => { - const source = new URL(target.baseUrl); - // Chromium validates __Host- cookies against their source scheme, - // even on localhost where Secure cookies work over HTTP. - if (cookie.secure) source.protocol = "https:"; - return { ...cookie, url: source.href }; - }), + identity.cookies.map((cookie) => ({ + ...cookie, + url: target.baseUrl, + })), ); } const page = await context.newPage(); diff --git a/e2e/src/target.ts b/e2e/src/target.ts index 0ad050c228..a410490537 100644 --- a/e2e/src/target.ts +++ b/e2e/src/target.ts @@ -21,17 +21,9 @@ export interface Identity { /** Headers that authenticate API requests (e.g. a session cookie). */ readonly headers?: Record; /** Cookies to inject into a browser context for a logged-in page. */ - readonly cookies?: ReadonlyArray<{ - readonly name: string; - readonly value: string; - readonly secure?: boolean; - }>; + readonly cookies?: ReadonlyArray<{ readonly name: string; readonly value: string }>; /** Credentials for surfaces that sign in themselves (Better Auth, OAuth consent). */ - readonly credentials?: { - readonly email: string; - readonly password: string; - readonly totpSecret?: string; - }; + readonly credentials?: { readonly email: string; readonly password: string }; } export interface Target { @@ -45,10 +37,7 @@ export interface Target { * `org: false` yields an identity with no active organization (for flows * that create one, like onboarding / billing limits). */ - readonly newIdentity: (options?: { - readonly org?: boolean; - readonly adminMfa?: boolean; - }) => Effect.Effect; + readonly newIdentity: (options?: { readonly org?: boolean }) => Effect.Effect; /** Headless OAuth consent for the MCP surface, when "mcp-oauth" is supported. */ readonly mcpConsent?: ( identity: Identity, diff --git a/e2e/targets/cloud.ts b/e2e/targets/cloud.ts index 1bed3701bf..b5d360a2ad 100644 --- a/e2e/targets/cloud.ts +++ b/e2e/targets/cloud.ts @@ -10,7 +10,6 @@ import { Effect } from "effect"; import { connectEmulator } from "@executor-js/emulate"; -import { verifyAdmin } from "../cloud/support/admin-mfa"; import { e2ePort } from "../src/ports"; import type { Identity, Target } from "../src/target"; @@ -72,7 +71,7 @@ export const cloudTarget = (): Target => ({ }); await workos.seed({ oauth: { default_access_token_ttl_seconds: seconds } }); }), - newIdentity: ({ org = true, adminMfa = true } = {}) => + newIdentity: ({ org = true } = {}) => Effect.promise(async (): Promise => { const label = `user-${randomUUID().slice(0, 8)}`; const email = `${label}@e2e.test`; @@ -97,7 +96,7 @@ export const cloudTarget = (): Target => ({ orgSlug = ((await response.json()) as { slug?: string }).slug ?? null; } const [name, value] = session.split(/=(.*)/s); - const identity: Identity = { + return { label: email, // The org selector header rides along exactly as the web client sends // it from the console URL's slug: org-scoped API reads fail closed @@ -109,9 +108,6 @@ export const cloudTarget = (): Target => ({ cookies: [{ name: name!, value: value! }], credentials: { email, password: "emulated" }, }; - return org && adminMfa - ? await Effect.runPromise(verifyAdmin(CLOUD_BASE_URL, identity)) - : identity; }), // MCP OAuth against the emulator's authorization server: complete the // hosted flow headlessly as this identity. diff --git a/packages/core/sdk/src/org-writes.test.ts b/packages/core/sdk/src/org-writes.test.ts index 5b995819fc..f30c5d14e7 100644 --- a/packages/core/sdk/src/org-writes.test.ts +++ b/packages/core/sdk/src/org-writes.test.ts @@ -319,7 +319,7 @@ describe("orgWrites: denied", () => { }).pipe(Effect.scoped), ); - it.effect("keeps the OAuth code redeemable until workspace access is restored", () => + it.effect("rechecks workspace authorization before persisting OAuth callback tokens", () => Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: [] }); const { admin, member } = yield* setup(); @@ -353,14 +353,6 @@ describe("orgWrites: denied", () => { ); expect(yield* member.connections.list({ owner: "org" })).toEqual([]); expect(yield* member.providers.items(ProviderKey.make("memory"))).toEqual(beforeItems); - expect((yield* server.requests).filter((request) => request.path === "/token")).toHaveLength( - 0, - ); - const connection = yield* admin.oauth.complete({ state: started.state, code: callback.code }); - expect(connection.owner).toBe("org"); - expect((yield* server.requests).filter((request) => request.path === "/token")).toHaveLength( - 1, - ); }).pipe(Effect.scoped), ); }); diff --git a/packages/core/sdk/src/testing.ts b/packages/core/sdk/src/testing.ts index 1d2cde67eb..da86dbe810 100644 --- a/packages/core/sdk/src/testing.ts +++ b/packages/core/sdk/src/testing.ts @@ -1,9 +1,7 @@ -import { createServer } from "node:http"; import { createConnection } from "node:net"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { Context, Data, Effect, Layer, Predicate, Schedule, Scope as EffectScope } from "effect"; import { - FetchHttpClient, HttpClient, HttpRouter, HttpServer, @@ -95,17 +93,6 @@ export const serveTestHttpServerLayer = ( EffectScope.Scope > => makeTestHttpServer(serverLayer); -// Bind the same address family returned below. On macOS an IPv6 wildcard -// can share a port with an unrelated IPv4 listener, sending tests to that service. -const testServerLayer = HttpServer.layerTestClient.pipe( - Layer.provide( - FetchHttpClient.layer.pipe( - Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })), - ), - ), - Layer.provideMerge(NodeHttpServer.layer(createServer, { port: 0, host: "127.0.0.1" })), -); - const makeTestHttpServer = ( serverLayer: Layer.Layer, ): Effect.Effect< @@ -115,7 +102,7 @@ const makeTestHttpServer = ( > => Effect.gen(function* () { const context = yield* Layer.build( - Layer.fresh(serverLayer.pipe(Layer.provideMerge(testServerLayer))), + Layer.fresh(serverLayer.pipe(Layer.provideMerge(NodeHttpServer.layerTest))), ).pipe(Effect.mapError((cause) => new TestHttpServerServeError({ cause }))); const server = Context.get(context, HttpServer.HttpServer); const address = server.address; diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts index 6e0fbf7346..0380e389f0 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -1,4 +1,3 @@ -import { createServer } from "node:http"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { Context, Data, Effect, Layer, Option, Predicate, Ref, Schema, Scope } from "effect"; import { createHash, randomUUID } from "node:crypto"; @@ -356,9 +355,7 @@ const serveOAuthTestHttpApp = ( Layer.fresh( HttpServer.serve( HttpServerRequest.HttpServerRequest.asEffect().pipe(Effect.flatMap(handler)), - ).pipe( - Layer.provideMerge(NodeHttpServer.layer(createServer, { port: 0, host: "127.0.0.1" })), - ), + ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), ), ).pipe(Effect.mapError((address) => new OAuthTestServerAddressError({ address }))); const server = Context.get(context, HttpServer.HttpServer); From e8655a6d631012bf2381c8c26408f4e03ae22967 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:52:01 -0700 Subject: [PATCH 2/2] Revert "Require MFA to unlock cloud administration (#2109)" This reverts commit d0ca1b6b0131c3921ef742b6904598d4aef4428a. --- .changeset/quiet-admin-unlock.md | 6 - apps/cloud/src/account/account-api.ts | 3 +- .../src/account/workos-account-service.ts | 10 +- apps/cloud/src/admin/admin-users-api.ts | 50 ++- apps/cloud/src/api/router.ts | 2 - apps/cloud/src/auth/admin-mfa-proof.ts | 89 ----- apps/cloud/src/auth/admin-mfa-routes.ts | 245 -------------- apps/cloud/src/auth/handlers.ts | 8 +- apps/cloud/src/auth/middleware-live.ts | 8 +- apps/cloud/src/auth/middleware.ts | 4 - .../src/auth/oauth-admin-verification.ts | 32 -- apps/cloud/src/auth/workos-auth-provider.ts | 10 +- apps/cloud/src/auth/workos.ts | 69 +--- apps/cloud/src/env-augment.d.ts | 6 - apps/cloud/src/extensions/billing/route.ts | 13 +- apps/cloud/src/extensions/routes.ts | 4 +- apps/cloud/src/mcp/auth-provider.ts | 2 +- apps/cloud/src/org/api.ts | 2 +- apps/cloud/src/org/auth-middleware.ts | 10 +- apps/cloud/src/org/handlers.ts | 5 +- apps/cloud/src/routes/app/api-keys.tsx | 12 +- apps/cloud/src/start.ts | 6 +- .../src/web/components/admin-verification.tsx | 307 ------------------ apps/cloud/src/web/shell.tsx | 22 +- apps/cloud/wrangler.jsonc | 7 - packages/core/api/src/handlers/oauth.ts | 16 +- packages/core/sdk/src/oauth-service.ts | 4 - packages/react/src/pages/api-keys.tsx | 15 +- 28 files changed, 75 insertions(+), 892 deletions(-) delete mode 100644 .changeset/quiet-admin-unlock.md delete mode 100644 apps/cloud/src/auth/admin-mfa-proof.ts delete mode 100644 apps/cloud/src/auth/admin-mfa-routes.ts delete mode 100644 apps/cloud/src/auth/oauth-admin-verification.ts delete mode 100644 apps/cloud/src/web/components/admin-verification.tsx diff --git a/.changeset/quiet-admin-unlock.md b/.changeset/quiet-admin-unlock.md deleted file mode 100644 index dd6aecafac..0000000000 --- a/.changeset/quiet-admin-unlock.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@executor-js/sdk": patch -"@executor-js/api": patch ---- - -Check workspace write permission before redeeming an OAuth code. Preserve denied callbacks for retry after verification and expose an authorization marker to host callback pages. diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts index 898fe3a178..d9aaf70d27 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -13,7 +13,6 @@ import { UserStoreService } from "../auth/context"; import { WorkOsMirror } from "../auth/workos-mirror"; import { sessionFromSealed, type Session } from "../auth/middleware"; import { WorkOSClient } from "../auth/workos"; -import { ADMIN_MFA_COOKIE } from "../auth/admin-mfa-proof"; import { AutumnService } from "../extensions/billing/service"; import { DbService } from "../db/db"; import { AccountCaller, workosAccountProvider } from "./workos-account-service"; @@ -70,7 +69,7 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ const request = yield* HttpServerRequest.HttpServerRequest; const cookieValue = request.cookies["wos-session"] ?? ""; const resolved = yield* workos - .authenticateSealedSession(cookieValue, request.cookies[ADMIN_MFA_COOKIE]) + .authenticateSealedSession(cookieValue) .pipe(Effect.orElseSucceed(() => null)); // The account API never re-sets the cookie, so the fallback sealed // session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie). diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts index 5ea7de5707..94f5aed511 100644 --- a/apps/cloud/src/account/workos-account-service.ts +++ b/apps/cloud/src/account/workos-account-service.ts @@ -144,15 +144,7 @@ export const workosAccountProvider: Layer.Layer< // moments ago is denied as soon as the write-through or the Events // reconciler has landed the change. const requireAdmin = (org: { readonly memberRole: "admin" | "member" }) => - Effect.gen(function* () { - if (org.memberRole !== "admin") return yield* new AccountForbidden(); - const session = yield* requireSession(); - if (session.adminVerified !== true) { - return yield* new AccountForbidden({ - message: "Verify your identity to use organization admin settings.", - }); - } - }); + org.memberRole === "admin" ? Effect.void : Effect.fail(new AccountForbidden()); // 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 — diff --git a/apps/cloud/src/admin/admin-users-api.ts b/apps/cloud/src/admin/admin-users-api.ts index f9132d9c6a..0c77c03a93 100644 --- a/apps/cloud/src/admin/admin-users-api.ts +++ b/apps/cloud/src/admin/admin-users-api.ts @@ -2,9 +2,19 @@ // Cloud admin users API — the shared, provider-neutral `AdminUsersHandlers` // backed by a WorkOS-authorized platform view, mounted at `/api/admin/users*`. // -// Only an active admin browser session with a completed second factor reaches -// this plane. Bearer credentials retain ordinary product access, never cross-user -// access, even when accompanied by a verified browser cookie. +// TWO credentials reach this plane, and they are the two an operator actually +// has: +// 1. an ORG-SCOPED api key -> `PlatformAuth`. The key IS the authority: WorkOS +// validated it and reported which org owns it, and there is no member +// behind it to check membership for. This is the machine credential +// (a customer's backend calling us). +// 2. an admin SESSION member -> the console. Requires the caller's mirrored +// membership (the shared `MemberDirectory` over the local membership +// mirror) to carry the `admin` role AND `active` status, matching the +// strictest existing cloud guard (`auth/handlers.ts`'s org-delete check) — +// a pending admin invite is not an admin. +// A plain member session, or a USER-scoped api key, is refused: both name one +// acting member, and this plane deliberately serves the whole tenant. // // The executor is built by `makePlatformExecutor` — `{ tenant, subject: // undefined, platformView: true }` — so the reads are tenant-wide and read-only @@ -44,8 +54,10 @@ import { } from "@executor-js/api"; import type { Executor } from "@executor-js/sdk"; +import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; import { WorkOsMirror } from "../auth/workos-mirror"; +import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider"; import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization"; import { WorkOSClient } from "../auth/workos"; import { DbService } from "../db/db"; @@ -54,7 +66,9 @@ import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; /** * Resolve the tenant this request may read, or fail with the neutral 401/403. * - * Returns only the authorized organization id so admin reads remain tenant-scoped. + * Returns only the organization id: nothing downstream needs to know WHICH of + * the two credentials got the caller here, and keeping the acting member out of + * the return value means no admin read can accidentally become subject-scoped. * Exported for its test only. */ export const authorizeTenant = ( @@ -62,11 +76,27 @@ export const authorizeTenant = ( ): Effect.Effect< string, AdminUsersUnauthorized | AdminUsersForbidden, - WorkOSClient | UserStoreService | MemberDirectory | WorkOsMirror + WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror > => Effect.gen(function* () { - if (request.headers.has("authorization")) return yield* new AdminUsersForbidden(); + // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`, + // which rejects org keys for the product plane) is what distinguishes an + // org key from a user key. + const bearer = yield* resolveBearerAuth(request).pipe( + // Every rejected-credential and infra failure collapses to one refusal: + // this plane must not report whether a key exists, belongs to another + // org, or merely lacks privilege. + Effect.catchCause(() => Effect.succeed(null)), + ); + if (bearer !== null) { + if (isPlatformAuth(bearer)) return bearer.organizationId; + // A user-scoped key authenticated fine but names one member; the platform + // plane has no honest way to serve it. + return yield* new AdminUsersForbidden(); + } + // (2) The session path: an active admin membership in the selected org, + // read from the mirror. const workos = yield* WorkOSClient; const session = yield* workos .authenticateRequest(request) @@ -85,7 +115,6 @@ export const authorizeTenant = ( ); if (!org) return yield* new AdminUsersForbidden(); if (org.memberRole !== "admin") return yield* new AdminUsersForbidden(); - if (session.adminVerified !== true) return yield* new AdminUsersForbidden(); return org.id; }); @@ -105,6 +134,7 @@ const withPlatformView = ()( Effect.gen(function* () { - const longLived = yield* Effect.context(); + const longLived = yield* Effect.context(); return (httpEffect) => Effect.gen(function* () { // Built inside the request body so the execution seams close over the diff --git a/apps/cloud/src/api/router.ts b/apps/cloud/src/api/router.ts index 5851158f26..8c80825ef2 100644 --- a/apps/cloud/src/api/router.ts +++ b/apps/cloud/src/api/router.ts @@ -11,7 +11,6 @@ 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"; @@ -42,7 +41,6 @@ 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.ts b/apps/cloud/src/auth/admin-mfa-proof.ts deleted file mode 100644 index c174ae59a2..0000000000 --- a/apps/cloud/src/auth/admin-mfa-proof.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Data, Effect, Option, Schema } from "effect"; -import { SignJWT, jwtVerify } from "jose"; - -/** HttpOnly cookies used only for the administrative verification flow. */ -export const ADMIN_MFA_COOKIE = "__Host-executor-admin-mfa"; -/** The pending challenge is bound to the same user and WorkOS session. */ -export const ADMIN_MFA_CHALLENGE_COOKIE = "__Host-executor-admin-challenge"; -/** Administrative verification expires after fifteen minutes. */ -export const ADMIN_MFA_TTL_SECONDS = 15 * 60; - -/** A verified WorkOS session, supplied by the authentication adapter. */ -export interface AdminMfaIdentity { - readonly userId: string; - readonly sessionId: string; -} - -const Proof = Schema.Struct({ - factorId: Schema.String, - challengeId: Schema.String, - mode: Schema.Literals(["enroll", "challenge"]), - exp: Schema.Number, -}); -const decodeProof = Schema.decodeUnknownOption(Proof); - -/** Signing failures are server failures; invalid input cookies are simply refused. */ -export class AdminMfaProofError extends Data.TaggedError("AdminMfaProofError")<{ - readonly cause: unknown; -}> {} - -type Purpose = "challenge" | "verified"; -const issuer = (purpose: Purpose) => `executor:admin-mfa:${purpose}`; -const key = (secret: string) => new TextEncoder().encode(secret); - -/** Sign a purpose-specific, session-bound proof with an explicit expiration. */ -export const signAdminMfaProof = ( - secret: string, - identity: AdminMfaIdentity, - purpose: Purpose, - proof: typeof Proof.Type, - now: number, -) => - Effect.tryPromise({ - try: () => - new SignJWT({ factorId: proof.factorId, challengeId: proof.challengeId, mode: proof.mode }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuer(issuer(purpose)) - .setSubject(identity.userId) - .setAudience(identity.sessionId) - .setIssuedAt(Math.floor(now / 1000)) - .setExpirationTime(proof.exp) - .sign(key(secret)), - catch: (cause) => new AdminMfaProofError({ cause }), - }); - -/** Reject expired, tampered, cross-user, cross-session, and wrong-purpose proofs. */ -export const readAdminMfaProof = ( - secret: string, - identity: AdminMfaIdentity, - purpose: Purpose, - token: string | undefined, - now: number, -) => { - if (!token) return Effect.succeed(null); - return Effect.tryPromise({ - try: () => - jwtVerify(token, key(secret), { - algorithms: ["HS256"], - issuer: issuer(purpose), - subject: identity.userId, - audience: identity.sessionId, - requiredClaims: ["exp", "iat", "sub", "aud"], - maxTokenAge: purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS, - currentDate: new Date(now), - }), - catch: (cause) => new AdminMfaProofError({ cause }), - }).pipe( - Effect.map(({ payload }) => { - const maxAge = purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS; - if ( - typeof payload.iat !== "number" || - typeof payload.exp !== "number" || - payload.exp > payload.iat + maxAge - ) - return null; - return Option.getOrNull(decodeProof(payload)); - }), - Effect.catchTag("AdminMfaProofError", () => Effect.succeed(null)), - ); -}; diff --git a/apps/cloud/src/auth/admin-mfa-routes.ts b/apps/cloud/src/auth/admin-mfa-routes.ts deleted file mode 100644 index 523ecb1316..0000000000 --- a/apps/cloud/src/auth/admin-mfa-routes.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { env } from "cloudflare:workers"; -import { Clock, Data, Duration, Effect, Layer, Option, Schema, Stream } from "effect"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { WorkOSClient } from "./workos"; -import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "./organization"; -import { - ADMIN_MFA_COOKIE, - ADMIN_MFA_CHALLENGE_COOKIE, - ADMIN_MFA_TTL_SECONDS, - readAdminMfaProof, - signAdminMfaProof, -} from "./admin-mfa-proof"; - -const codeBody = Schema.Struct({ code: Schema.String.check(Schema.isPattern(/^\d{6}$/)) }); -const parseCodeBody = Schema.decodeUnknownOption(Schema.fromJsonString(codeBody)); -class RateLimitError extends Data.TaggedError("AdminMfaRateLimitError")<{ - readonly cause: unknown; -}> {} -class CodeBodyTooLarge extends Data.TaggedError("CodeBodyTooLarge") {} -const cookieOptions = { - path: "/", - httpOnly: true, - secure: true, - sameSite: "strict" as const, - maxAge: Duration.seconds(ADMIN_MFA_TTL_SECONDS), -}; -const json = (body: unknown, status = 200) => - HttpServerResponse.jsonUnsafe(body, { status, headers: { "cache-control": "no-store" } }); - -const handler = (action: "status" | "start" | "verify" | "cancel" | "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") { - return json( - org.memberRole !== "admin" - ? { state: "member" } - : session.adminVerified - ? { state: "verified", expiresAt: session.adminVerificationExpiresAt } - : { state: "required" }, - ); - } - if (org.memberRole !== "admin") return json({ message: "Admin access is required." }, 403); - - if (action === "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( - // OAuth providers return by top-level GET. Carry the verification proof - // on that callback so an authorized Workspace connection can finish. - HttpServerResponse.setCookieUnsafe(ADMIN_MFA_COOKIE, token, { - ...cookieOptions, - sameSite: "lax", - }), - 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 c62934948d..6453a0547f 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -15,7 +15,6 @@ import { import { MemberDirectory, NoOrganization } from "@executor-js/api/server"; // Pure constants/codec module (no React) — safe in the backend graph. import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint"; -import { ADMIN_MFA_COOKIE, ADMIN_MFA_CHALLENGE_COOKIE } from "./admin-mfa-proof"; import { SessionContext, SessionCookies } from "./middleware"; import { encodeLoginState, decodeLoginState } from "./login-state"; import { safeReturnTo } from "./return-to"; @@ -354,9 +353,6 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( "wos-session", ), AUTH_HINT_COOKIE, - ).pipe( - (response) => deleteResponseCookie(response, ADMIN_MFA_COOKIE), - (response) => deleteResponseCookie(response, ADMIN_MFA_CHALLENGE_COOKIE), ); }), ) @@ -566,7 +562,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // not an admin) and reported its role, so the gate is that one // value: a member removed or demoted moments ago is denied once the // write-through or the Events reconciler has landed the change. - if (session.memberRole !== "admin" || session.adminVerified !== true) { + if (session.memberRole !== "admin") { return yield* new OrganizationDeletionForbidden(); } @@ -833,7 +829,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( { accountId: owner.accountId, organizationId: owner.organizationId, - orgRole: owner.adminVerified === true ? owner.memberRole : "member", + orgRole: owner.memberRole, }, { action: payload.action, diff --git a/apps/cloud/src/auth/middleware-live.ts b/apps/cloud/src/auth/middleware-live.ts index 1b30675669..16ac3a4ff6 100644 --- a/apps/cloud/src/auth/middleware-live.ts +++ b/apps/cloud/src/auth/middleware-live.ts @@ -4,8 +4,7 @@ // --------------------------------------------------------------------------- import { Effect, Layer, Redacted } from "effect"; -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { ADMIN_MFA_COOKIE } from "./admin-mfa-proof"; +import { HttpServerResponse } from "effect/unstable/http"; import { AuthContext, NoOrganization, Unauthorized } from "@executor-js/api/server"; @@ -28,10 +27,7 @@ export const SessionAuthLive = Layer.effect( cookie: (httpEffect, { credential }) => Effect.gen(function* () { const result = yield* workos - .authenticateSealedSession( - Redacted.value(credential), - (yield* HttpServerRequest.HttpServerRequest).cookies[ADMIN_MFA_COOKIE], - ) + .authenticateSealedSession(Redacted.value(credential)) .pipe(Effect.orElseSucceed(() => null)); if (!result) { diff --git a/apps/cloud/src/auth/middleware.ts b/apps/cloud/src/auth/middleware.ts index 8f5ea36e13..cecd1a64f4 100644 --- a/apps/cloud/src/auth/middleware.ts +++ b/apps/cloud/src/auth/middleware.ts @@ -38,8 +38,6 @@ export type Session = { readonly organizationId: string | null; readonly sealedSession: string; readonly refreshedSession: string | null; - /** True only while a verified second factor remains bound to this session. */ - readonly adminVerified?: boolean; }; export class SessionContext extends Context.Service()( @@ -71,7 +69,6 @@ export class SessionCookies extends Context.Service => { - if ( - response.status !== 403 || - request.method !== "GET" || - response.headers.get("x-executor-error") !== "org_write_denied" - ) - return response; - const url = new URL(request.url); - const state = decodeOAuthCallbackState(url.searchParams.get("state")); - if (!state) return response; - const headers = new Headers(response.headers); - headers.delete("content-length"); - headers.set("content-type", "text/html; charset=utf-8"); - headers.set("cache-control", "no-store"); - headers.set("referrer-policy", "no-referrer"); - headers.set( - "content-security-policy", - "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'", - ); - // The callback stays in this tab. No provider code or state enters another URL, - // frontend telemetry, browser storage, or the verification tab's referrer. - return new Response( - `Unlock administration · Executor

Unlock administration

Unlock administration in a new tab. Then return here to finish the Workspace connection. Personal connections do not need this step.

Unlock administrationContinue connection`, - { status: 200, headers }, - ); -}; diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index b45173a1da..7165a93042 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -162,7 +162,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => avatarUrl: null, roles: [], orgRoleModel: "organization", - orgRole: "member", + orgRole: org.memberRole, } satisfies Principal; }); @@ -173,8 +173,8 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => * can accidentally treat it as a member, and the executor built from it binds * `subject: null` + the read-only tenant reach rather than inventing a subject. * - * This credential grants shared catalog reads only. Cross-user admin reads - * require an MFA-verified browser session. + * The `/admin/*` mount turns this into an executor with `{ tenant: + * organizationId, subject: undefined, platformView: true }`. */ export interface PlatformAuth { readonly kind: "platform"; @@ -272,7 +272,7 @@ export const resolveBearerAuth = ( avatarUrl: null, roles: [], orgRoleModel: "organization", - orgRole: "member", + orgRole: org.memberRole, } satisfies Principal; }); @@ -354,7 +354,7 @@ export const resolveSessionPrincipal = (request: Request) => avatarUrl: session.avatarUrl ?? null, roles: [], orgRoleModel: "organization", - orgRole: session.adminVerified === true ? org.memberRole : "member", + orgRole: org.memberRole, } satisfies Principal; }); diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 189f01704a..918a7e8556 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -16,7 +16,6 @@ import { decodeJwt, jwtVerify } from "jose"; import { workosAccessTokenOptions } from "./access-token-options"; import { JWKSInvalid, JWKSNoMatchingKey, JWKSTimeout } from "jose/errors"; import { parseCookie } from "./cookies"; -import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "./admin-mfa-proof"; import { createCachedRemoteJWKSet, type CachedRemoteJWKSet } from "./jwks-cache"; import { ServiceAdapterError, @@ -432,18 +431,7 @@ 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, adminProof?: string) => + const authenticateSealedSession = (sessionData: string) => Effect.gen(function* () { if (!sessionData) return null; @@ -459,16 +447,6 @@ const make = Effect.gen(function* () { ); if (isLocalSessionValid(local)) { - const proof = yield* readAdminMfaProof( - cookiePassword, - { - userId: local.session.user.id, - sessionId: local.sessionId, - }, - "verified", - adminProof, - Date.now(), - ); return { userId: local.session.user.id, email: local.session.user.email, @@ -477,8 +455,6 @@ const make = Effect.gen(function* () { avatarUrl: local.session.user.profilePictureUrl, organizationId: local.organizationId, sessionId: local.sessionId, - adminVerified: proof !== null, - adminVerificationExpiresAt: proof?.exp ?? null, refreshedSession: undefined as string | undefined, }; } @@ -493,17 +469,6 @@ const make = Effect.gen(function* () { if (!refreshed.authenticated || !("sealedSession" in refreshed) || !refreshed.sealedSession) return null; - const proof = yield* readAdminMfaProof( - cookiePassword, - { - userId: refreshed.user.id, - sessionId: refreshed.sessionId, - }, - "verified", - adminProof, - Date.now(), - ); - return { userId: refreshed.user.id, email: refreshed.user.email, @@ -512,38 +477,11 @@ const make = Effect.gen(function* () { avatarUrl: refreshed.user.profilePictureUrl, organizationId: refreshed.organizationId, sessionId: refreshed.sessionId, - adminVerified: proof !== null, - adminVerificationExpiresAt: proof?.exp ?? null, refreshedSession: refreshed.sealedSession, }; }); return { - /** List factors belonging to this user; callers cannot supply another user's factor. */ - listMfaFactors: (userId: string) => - useMfa("userManagement.listAuthFactors", (wos) => - wos.userManagement - .listAuthFactors({ userId, limit: 100 }) - .then((page) => page.autoPagination()), - ), - /** Begin AuthKit's user-bound TOTP enrollment. The secret is returned only to that user. */ - enrollMfa: (userId: string, email: string) => - useMfa("userManagement.enrollAuthFactor", (wos) => - wos.userManagement.enrollAuthFactor({ - userId, - type: "totp", - totpIssuer: "Executor", - totpUser: email, - }), - ), - /** Challenge an already resolved factor. */ - challengeMfa: (authenticationFactorId: string) => - useMfa("mfa.challengeFactor", (wos) => wos.mfa.challengeFactor({ authenticationFactorId })), - /** Verify a TOTP code with WorkOS; never log the code or factor secret. */ - verifyMfa: (authenticationChallengeId: string, code: string) => - useMfa("mfa.verifyChallenge", (wos) => - wos.mfa.verifyChallenge({ authenticationChallengeId, code }), - ), getAuthorizationUrl: (redirectUri: string, state?: string) => workos.userManagement.getAuthorizationUrl({ provider: "authkit", @@ -657,10 +595,7 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const sessionData = parseCookie(request.headers.get("cookie"), COOKIE_NAME); if (!sessionData) return null; - return yield* authenticateSealedSession( - sessionData, - parseCookie(request.headers.get("cookie"), ADMIN_MFA_COOKIE) ?? undefined, - ); + return yield* authenticateSealedSession(sessionData); }), /** diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 017570cf1a..715991f394 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -5,12 +5,6 @@ declare global { namespace Cloudflare { interface Env { - /** TOTP enrollment and verification attempts; absence refuses verification. */ - ADMIN_MFA_RATE_LIMITER?: { - readonly limit: (options: { - readonly key: string; - }) => Promise<{ readonly success: boolean }>; - }; // Observability // Worker version metadata binding (wrangler.jsonc `version_metadata`). // Optional so test workers and local setups without the binding still diff --git a/apps/cloud/src/extensions/billing/route.ts b/apps/cloud/src/extensions/billing/route.ts index 8269727272..e353c6c2be 100644 --- a/apps/cloud/src/extensions/billing/route.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -87,19 +87,8 @@ const handler = Effect.gen(function* () { }); } const org = yield* resolveBillingOrganization(webRequest, session); - const url = new URL(webRequest.url); - const ordinaryRead = - request.method === "POST" && - (url.pathname === "/api/billing/getOrCreateCustomer" || - url.pathname === "/api/billing/listPlans"); - if (!ordinaryRead && (org.memberRole !== "admin" || session.adminVerified !== true)) { - return yield* new HttpResponseError({ - status: 403, - code: "admin_mfa_required", - message: "Unlock administration with your authenticator to manage billing.", - }); - } + const url = new URL(webRequest.url); const body = request.method !== "GET" && request.method !== "HEAD" ? yield* Effect.mapError( diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index c710597884..f1c4389fe7 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -38,7 +38,6 @@ 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"; @@ -116,7 +115,7 @@ export const makeCloudExtensionRoutes = ( // The tenant-wide admin plane (`/api/admin/users*`). Mounted as an extension // rather than on the protected API because the protected plane's middleware // binds a product-view executor to one acting member — this one authorizes an - // verified admin session and builds a subject-less platform view. + // org key (or an admin session) and builds a subject-less platform view. const AdminUsersRoutes = makeCloudAdminUsersRoutes(rsLive, { router: apiPrefixedRouter, }); @@ -132,7 +131,6 @@ export const makeCloudExtensionRoutes = ( }); return [ - AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(rsLive).layer)), SessionRoutes, OrgRoutes, AdminUsersRoutes, diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts index 7ab1d5c683..b05685af58 100644 --- a/apps/cloud/src/mcp/auth-provider.ts +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -113,7 +113,7 @@ const principalFromToken = ( organizationName: organization.name, ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), orgRoleModel: "organization", - orgRole: "member", + orgRole: organization.memberRole, email: "", name: null, avatarUrl: null, diff --git a/apps/cloud/src/org/api.ts b/apps/cloud/src/org/api.ts index 47587ed852..55cfabfd31 100644 --- a/apps/cloud/src/org/api.ts +++ b/apps/cloud/src/org/api.ts @@ -41,7 +41,7 @@ export class OrgApi extends HttpApiGroup.make("org") .add( HttpApiEndpoint.get("listDomains", "/org/domains", { success: DomainsResponse, - error: [WorkOSError, Forbidden], + error: WorkOSError, }), ) .add( diff --git a/apps/cloud/src/org/auth-middleware.ts b/apps/cloud/src/org/auth-middleware.ts index 4be83a5dcb..9c61236f3b 100644 --- a/apps/cloud/src/org/auth-middleware.ts +++ b/apps/cloud/src/org/auth-middleware.ts @@ -12,7 +12,6 @@ import { sessionFromSealed } from "../auth/middleware"; import { WorkOsMirror } from "../auth/workos-mirror"; import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; import { WorkOSClient } from "../auth/workos"; -import { ADMIN_MFA_COOKIE } from "../auth/admin-mfa-proof"; import { DbService } from "../db/db"; const unauthorized = () => @@ -42,7 +41,7 @@ const noOrganization = () => */ export class OrgMemberRole extends Context.Service< OrgMemberRole, - { readonly memberRole: "admin" | "member"; readonly adminVerified?: boolean } + { readonly memberRole: "admin" | "member" } >()("@executor-js/cloud/OrgMemberRole") {} const OrgAuthMiddleware = HttpRouter.middleware<{ @@ -56,7 +55,7 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ const request = yield* HttpServerRequest.HttpServerRequest; const cookieValue = request.cookies["wos-session"] ?? ""; const result = yield* workos - .authenticateSealedSession(cookieValue, request.cookies[ADMIN_MFA_COOKIE]) + .authenticateSealedSession(cookieValue) .pipe(Effect.orElseSucceed(() => null)); if (!result) return unauthorized(); @@ -85,10 +84,7 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ return yield* Effect.provideContext( httpEffect, Context.make(AuthContext, auth).pipe( - Context.add(OrgMemberRole, { - memberRole: org.memberRole, - adminVerified: result.adminVerified, - }), + Context.add(OrgMemberRole, { memberRole: org.memberRole }), ), ); }).pipe(Effect.provideContext(captured)); diff --git a/apps/cloud/src/org/handlers.ts b/apps/cloud/src/org/handlers.ts index 81415c5b25..64c9f329b2 100644 --- a/apps/cloud/src/org/handlers.ts +++ b/apps/cloud/src/org/handlers.ts @@ -29,8 +29,8 @@ import { OrgMemberRole } from "./auth-middleware"; * with `Forbidden` for a member. Exported for its test only. */ export const requireAdmin = Effect.gen(function* () { - const { memberRole, adminVerified } = yield* OrgMemberRole; - if (memberRole !== "admin" || adminVerified !== true) return yield* new Forbidden(); + const { memberRole } = yield* OrgMemberRole; + if (memberRole !== "admin") return yield* new Forbidden(); }); // Target-ownership check — independent of caller privilege. `requireAdmin` @@ -58,7 +58,6 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => handlers .handle("listDomains", () => Effect.gen(function* () { - yield* requireAdmin; const auth = yield* AuthContext; const workos = yield* WorkOSClient; const org = yield* workos.getOrganization(auth.organizationId); diff --git a/apps/cloud/src/routes/app/api-keys.tsx b/apps/cloud/src/routes/app/api-keys.tsx index b796caa6ac..b686410175 100644 --- a/apps/cloud/src/routes/app/api-keys.tsx +++ b/apps/cloud/src/routes/app/api-keys.tsx @@ -1,8 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { ApiKeysPage, OrgApiKeysSection } from "@executor-js/react/pages/api-keys"; -import { AdminVerification } from "../../web/components/admin-verification"; - // Cloud renders the SHARED API-keys page over the provider-neutral // `/account/api-keys` surface — identical UI to self-host, plus the // cloud-only Organization keys section (self-host's provider refuses @@ -12,13 +10,5 @@ export const Route = createFileRoute("/{-$orgSlug}/api-keys")({ }); function CloudApiKeysPage() { - return ( - - - - } - /> - ); + return } />; } diff --git a/apps/cloud/src/start.ts b/apps/cloud/src/start.ts index eff7999719..aaadc1a521 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -1,4 +1,3 @@ -import { oauthAdminVerificationResponse } from "./auth/oauth-admin-verification"; import { createMiddleware, createStart } from "@tanstack/react-start"; import { decodeOAuthCallbackState } from "@executor-js/sdk/shared"; @@ -92,10 +91,7 @@ const appRequestMiddleware = createMiddleware({ type: "request" }).server( if (isAppOwnedPath(pathname)) { const scopedRequest = pathname === OAUTH_CALLBACK_PATH ? oauthCallbackOrgScopedRequest(request) : request; - const response = await (await getApp()).handler(prepareMcpOrgScope(scopedRequest)); - return pathname === OAUTH_CALLBACK_PATH - ? oauthAdminVerificationResponse(request, response) - : response; + return (await getApp()).handler(prepareMcpOrgScope(scopedRequest)); } return next(); }, diff --git a/apps/cloud/src/web/components/admin-verification.tsx b/apps/cloud/src/web/components/admin-verification.tsx deleted file mode 100644 index 3d510be3d4..0000000000 --- a/apps/cloud/src/web/components/admin-verification.tsx +++ /dev/null @@ -1,307 +0,0 @@ -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("member") }), - Schema.Struct({ state: Schema.Literal("required") }), - Schema.Struct({ state: Schema.Literal("verified"), expiresAt: Schema.Number }), -]); -const Challenge = Schema.Union([ - Schema.Struct({ kind: Schema.Literal("challenge") }), - Schema.Struct({ - kind: Schema.Literal("enroll"), - secret: Schema.String, - qrCode: Schema.String.check(Schema.isPattern(/^data:image\/png;base64,/)), - }), -]); -const Message = Schema.Struct({ message: Schema.String }); -const Verified = Schema.Struct({ verified: Schema.Literal(true) }); -const Canceled = Schema.Struct({ canceled: Schema.Literal(true) }); -const unavailable = "Verification is unavailable. Try again."; - -class VerificationError extends Data.TaggedError("VerificationError")<{ - readonly message: string; -}> {} -const decodeStatus = Schema.decodeUnknownOption(Status); -const decodeChallenge = Schema.decodeUnknownOption(Challenge); -const decodeMessage = Schema.decodeUnknownOption(Message); -const decodeVerified = Schema.decodeUnknownOption(Verified); -const decodeCanceled = Schema.decodeUnknownOption(Canceled); - -function request( - path: string, - decode: (value: unknown) => Option.Option, - body?: Readonly>, -): Effect.Effect { - return Effect.gen(function* () { - const { response, raw } = yield* Effect.gen(function* () { - const client = yield* HttpClient.HttpClient; - const url = `/api/auth/admin-mfa${path}`; - const base = body === undefined ? HttpClientRequest.get(url) : HttpClientRequest.post(url); - const payload = body === undefined ? base : yield* HttpClientRequest.bodyJson(base, body); - const response = yield* client.execute( - HttpClientRequest.setHeaders(payload, getExecutorOrganizationHeaders()), - ); - const raw = yield* response.json; - return { response, raw }; - }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.mapError(() => new VerificationError({ message: unavailable })), - ); - if (response.status < 200 || response.status >= 300) { - const message = Option.getOrNull(decodeMessage(raw)); - return yield* new VerificationError({ message: message?.message ?? unavailable }); - } - const parsed = decode(raw); - if (Option.isNone(parsed)) return yield* new VerificationError({ message: unavailable }); - return parsed.value; - }); -} - -/** Require a second factor before mounting cloud admin controls. Members retain their existing view. */ -export function AdminVerification({ - children, - notice = false, -}: { - readonly children?: ReactNode; - readonly notice?: boolean; -}) { - const auth = useAuth(); - const scope = auth.status === "authenticated" ? auth.organization?.id : undefined; - if (!scope) return null; - return ( - - {children} - - ); -} - -function VerificationFlow({ - children, - notice, -}: { - readonly children?: ReactNode; - readonly notice: boolean; -}) { - 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 (notice) { - if (!status || status.state === "member") return null; - return ( -
- {status.state === "verified" ? ( - <> - Administration is unlocked for this session.{" "} - - - ) : ( - <> - previous} className="underline"> - Unlock administration - {" "} - to change workspace settings. Personal connections and tools remain available. - - )} -
- ); - } - if (status?.state === "member" || status?.state === "verified") return children; - - return ( -
-

Unlock administration

-

- Use an authenticator app to unlock admin settings for 15 minutes. You can keep using - personal connections and tools without setting this up. -

- 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/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index 767b26ee0b..99a60178df 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -1,5 +1,4 @@ import type React from "react"; -import { Outlet, useLocation, useParams } from "@tanstack/react-router"; import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav"; @@ -7,7 +6,6 @@ import { trackEvent } from "@executor-js/react/api/analytics"; import { AUTH_PATHS } from "../auth/api"; import { OrgMenuSlot } from "./components/org-menu-slot"; import { SupportSlot } from "./components/support-slot"; -import { AdminVerification } from "./components/admin-verification"; // --------------------------------------------------------------------------- // Cloud shell — the SHARED multiplayer shell, identical to self-host, with @@ -49,31 +47,13 @@ const signOut = () => { export function Shell(props: { readonly content?: React.ReactNode }) { const items = useAdminNavItems(navItems, adminNavItems); - const { orgSlug } = useParams({ strict: false }); - const pathname = useLocation({ select: (location) => location.pathname }); - const path = orgSlug ? pathname.slice(orgSlug.length + 1) : pathname; - const adminPage = ["/org", "/users", "/billing"].some( - (section) => path === section || path.startsWith(`${section}/`), - ); return ( } supportSlot={} - content={ - props.content ?? - (adminPage ? ( - - - - ) : ( - <> - - - - )) - } + content={props.content} /> ); } diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 4ee8b7a2b4..6d92064589 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -28,13 +28,6 @@ "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/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index 9e7eea17f4..eb4b1f939b 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -209,7 +209,6 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler capture( Effect.gen(function* () { const executor = yield* ExecutorService; - let orgWriteDenied = false; const html = yield* runOAuthCallback({ complete: ({ state, code, callbackDomain }) => executor.oauth @@ -224,11 +223,6 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler { toolSync: "background" }, ) .pipe( - Effect.tapErrorTag("OrgWriteDeniedError", () => - Effect.sync(() => { - orgWriteDenied = true; - }), - ), Effect.tapError((cause: unknown) => Effect.logError("OAuth callback completion failed", cause), ), @@ -237,15 +231,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler toErrorMessage: toPopupErrorMessage, channelName: OAUTH_POPUP_CHANNEL, }); - // Hosts can offer a verification path without parsing rendered HTML. - // Authorization failed before consuming the OAuth state or provider code. - const response = HttpServerResponse.html(html); - return orgWriteDenied - ? response.pipe( - HttpServerResponse.setStatus(403), - HttpServerResponse.setHeader("x-executor-error", "org_write_denied"), - ) - : response; + return HttpServerResponse.html(html); }), ), ), diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index de49a7352e..7d8d7d46ea 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -2207,10 +2207,6 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { return yield* new OAuthSessionNotFoundError({ state: input.state }); } - // Check before exchanging a single-use provider code. A user can unlock - // administration and retry this callback without restarting consent. - yield* deps.guardOrgWrite(session.owner); - // Reload the SAME app `start` resolved, by its explicit recorded owner. const client = yield* loadClient(session.clientOwner, session.clientSlug); if (!client) { diff --git a/packages/react/src/pages/api-keys.tsx b/packages/react/src/pages/api-keys.tsx index 37a6e6f0e1..cebd2194b7 100644 --- a/packages/react/src/pages/api-keys.tsx +++ b/packages/react/src/pages/api-keys.tsx @@ -380,7 +380,7 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode }) { // --------------------------------------------------------------------------- // Organization keys — the admin-only, org-owned credentials for the read-only -// shared product API. A separate section rather than rows in the table +// admin API (`/api/admin/*`). A separate section rather than rows in the table // above because the two key kinds answer different questions: a personal key // acts AS the member who minted it on the product plane; an org key has no // member behind it and reads the whole tenant. @@ -456,8 +456,9 @@ function OrgApiKeysSectionBody() {

Organization keys

- Read-only keys for shared integrations and tools. They cannot read other users’ personal - data or change workspace settings. Only admins can manage these keys. + Read-only keys owned by the organization, not a member. They authenticate the admin API + (who are my users, what have they connected) and cannot act as anyone or write anything. + Admins only.