diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index 48db9c2df3..1c80219263 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -59,6 +59,7 @@ const session = (accountId: string) => ({ name: null, avatarUrl: null, organizationId: ORG, + adminVerified: true, sealedSession: "sealed", refreshedSession: null, }); diff --git a/apps/cloud/src/admin/admin-users-api.node.test.ts b/apps/cloud/src/admin/admin-users-api.node.test.ts index 68f796bea3..844839d24e 100644 --- a/apps/cloud/src/admin/admin-users-api.node.test.ts +++ b/apps/cloud/src/admin/admin-users-api.node.test.ts @@ -135,7 +135,7 @@ const stubMirror = Layer.succeed( // Only session authentication is served; membership is read from the mirror, // so any other WorkOS call fails the test. -const stubWorkOS = (userId: string) => +const stubWorkOS = (userId: string, adminVerified: boolean) => Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { @@ -144,6 +144,7 @@ const stubWorkOS = (userId: string) => return () => Effect.succeed({ userId, + adminVerified, email: `${userId}@placeholder.test`, organizationId: null, }); @@ -153,18 +154,37 @@ const stubWorkOS = (userId: string) => }), ); -const authorizeAs = (userId: string) => +const authorizeAs = (userId: string, adminVerified = true, authorization?: string) => authorizeTenant( new Request("https://admin.invalid", { - headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG }, + headers: { + cookie: "wos-session=sealed", + [ORG_SELECTOR_HEADER]: ORG, + ...(authorization === undefined ? {} : { authorization }), + }, }), ).pipe( Effect.provide( - Layer.mergeAll(stubDirectory, stubApiKeys, stubUsers, stubWorkOS(userId), stubMirror), + Layer.mergeAll( + stubDirectory, + stubApiKeys, + stubUsers, + stubWorkOS(userId, adminVerified), + 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"); @@ -172,6 +192,14 @@ describe("authorizeTenant · admin session", () => { }), ); + it.effect("an admin without a second factor is forbidden", () => + Effect.gen(function* () { + expect(yield* Effect.flip(authorizeAs("user_admin", false))).toBeInstanceOf( + AdminUsersForbidden, + ); + }), + ); + it.effect("an active plain member is forbidden", () => Effect.gen(function* () { const error = yield* Effect.flip(authorizeAs("user_member")); diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index aac518b0f6..3167527041 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: "member", + role: "admin", 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 edf2a0796c..367f1aee29 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: "member", + role: "admin", 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 new file mode 100644 index 0000000000..77adc7538f --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-proof.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { SignJWT } from "jose"; +import { readAdminMfaProof, signAdminMfaProof } from "./admin-mfa-proof"; + +const secret = "a-test-only-cookie-password-of-32-characters"; +const identity = { userId: "user_test", sessionId: "session_test" }; +const now = 1_800_000_000_000; +const proof = { + mode: "challenge" as const, + factorId: "factor_test", + challengeId: "challenge_test", + exp: now / 1000 + 900, +}; +const signed = signAdminMfaProof(secret, identity, "verified", proof, now); + +describe("admin verification cookie", () => { + it.effect("accepts a valid proof for the same user and session", () => + Effect.gen(function* () { + const token = yield* signed; + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toEqual(proof); + }), + ); + + it.effect("refuses missing, modified, and unsigned cookies", () => + Effect.gen(function* () { + const token = yield* signed; + const parts = token.split("."); + const unsigned = `${btoa('{"alg":"none"}')}.${parts[1]}.`; + for (const value of [ + undefined, + "", + "bad.cookie", + `${token.slice(0, 50)}x${token.slice(51)}`, + unsigned, + ]) { + expect(yield* readAdminMfaProof(secret, identity, "verified", value, now)).toBeNull(); + } + }), + ); + + it.effect("refuses another session, another user, and another signing key", () => + Effect.gen(function* () { + const token = yield* signed; + for (const other of [ + { ...identity, userId: "other" }, + { ...identity, sessionId: "other" }, + ]) { + expect(yield* readAdminMfaProof(secret, other, "verified", token, now)).toBeNull(); + } + expect( + yield* readAdminMfaProof(`${secret}-rotated`, identity, "verified", token, now), + ).toBeNull(); + }), + ); + + it.effect("cannot promote an unfinished challenge to verified access", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "challenge", + { ...proof, mode: "enroll", exp: now / 1000 + 300 }, + now, + ); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "challenge", token, now + 299_000), + ).not.toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "challenge", token, now + 300_000), + ).toBeNull(); + }), + ); + + it.effect("expires at fifteen minutes and refuses a future-issued cookie", () => + Effect.gen(function* () { + const token = yield* signed; + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 899_000), + ).not.toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 900_000), + ).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now - 10_000), + ).toBeNull(); + }), + ); + + it.effect("caps token age even when the supplied expiration is longer", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "verified", + { ...proof, exp: now / 1000 + 86400 }, + now, + ); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 901_000), + ).toBeNull(); + }), + ); + + it.effect("rejects a signed cookie with missing issued-at or another algorithm", () => + Effect.gen(function* () { + for (const algorithm of ["HS256", "HS384"]) { + const jwt = new SignJWT({ ...proof }) + .setProtectedHeader({ alg: algorithm }) + .setIssuer("executor:admin-mfa:verified") + .setSubject(identity.userId) + .setAudience(identity.sessionId); + // HS256 lacks iat; HS384 is otherwise valid but outside the allowlist. + if (algorithm === "HS384") jwt.setIssuedAt(now / 1000); + const token = yield* Effect.promise(() => jwt.sign(new TextEncoder().encode(secret))); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + } + }), + ); +}); diff --git a/apps/cloud/src/auth/mirror-feeders.node.test.ts b/apps/cloud/src/auth/mirror-feeders.node.test.ts index b1cb9ba1de..10443449aa 100644 --- a/apps/cloud/src/auth/mirror-feeders.node.test.ts +++ b/apps/cloud/src/auth/mirror-feeders.node.test.ts @@ -228,6 +228,7 @@ describe("login callback", () => { organizationId: undefined, accessToken: "access", refreshToken: "refresh", + adminVerified: true, sealedSession: "sealed", }), listUserMemberships: (id) => { @@ -593,6 +594,7 @@ describe("session handlers read membership from the mirror", () => { ...options.workos, authenticateSealedSession: () => Effect.succeed({ + adminVerified: true, userId, email: `${userId}@placeholder.test`, organizationId: null, @@ -1099,6 +1101,7 @@ describe("account service writes through to the mirror", () => { name: null, avatarUrl: null, organizationId: null, + adminVerified: true, sealedSession: "sealed", refreshedSession: null, }); diff --git a/apps/cloud/src/auth/oauth-admin-verification.test.ts b/apps/cloud/src/auth/oauth-admin-verification.test.ts new file mode 100644 index 0000000000..91571851a3 --- /dev/null +++ b/apps/cloud/src/auth/oauth-admin-verification.test.ts @@ -0,0 +1,40 @@ +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 f972a2e757..f652213c2c 100644 --- a/apps/cloud/src/auth/org-selector-auth.node.test.ts +++ b/apps/cloud/src/auth/org-selector-auth.node.test.ts @@ -99,6 +99,7 @@ const stubWorkOS = Layer.succeed( userId: MEMBER, email: "u@e2e.test", organizationId: SESSION_ORG, + adminVerified: true, }); } // Membership is read from the mirror, never from WorkOS: any WorkOS diff --git a/apps/cloud/src/auth/workos.node.test.ts b/apps/cloud/src/auth/workos.node.test.ts index ca81b7717a..cadeb1078b 100644 --- a/apps/cloud/src/auth/workos.node.test.ts +++ b/apps/cloud/src/auth/workos.node.test.ts @@ -1,6 +1,7 @@ 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"; @@ -174,7 +175,7 @@ const withWorkOSStub = async ( }); }; -const runAuthenticate = (sessionData: string, baseUrl: string) => { +const runAuthenticate = (sessionData: string, baseUrl: string, proof?: string) => { Object.assign(env, { WORKOS_API_KEY: API_KEY, WORKOS_CLIENT_ID: CLIENT_ID, @@ -185,12 +186,54 @@ const runAuthenticate = (sessionData: string, baseUrl: string) => { return Effect.runPromise( Effect.gen(function* () { const workos = yield* WorkOSClient; - return yield* workos.authenticateSealedSession(sessionData); + return yield* workos.authenticateSealedSession(sessionData, proof); }).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) => { @@ -211,6 +254,8 @@ describe("authenticateSealedSession", () => { organizationId: "org_test", sessionId: "session_valid", refreshedSession: undefined, + adminVerified: false, + adminVerificationExpiresAt: null, }); expect(stub.requests()).toEqual([ { method: "GET", path: `/sso/jwks/${CLIENT_ID}`, body: null }, diff --git a/apps/cloud/src/mcp/auth-provider.test.ts b/apps/cloud/src/mcp/auth-provider.test.ts index 1a88793d69..c4a580841b 100644 --- a/apps/cloud/src/mcp/auth-provider.test.ts +++ b/apps/cloud/src/mcp/auth-provider.test.ts @@ -165,7 +165,9 @@ 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, "the live membership role reaches the MCP session").toBe("admin"); + expect(principal?.orgRole, "machine tokens never grant workspace administration").toBe( + "member", + ); 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 1cae2aa9a2..4a564d7a32 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -74,7 +74,7 @@ const provide = ( ): Layer.Layer => Layer.mergeAll( Layer.succeed(AuthContext)(adminAuth), - Layer.succeed(OrgMemberRole)({ memberRole }), + Layer.succeed(OrgMemberRole)({ memberRole, adminVerified: true }), stubWorkOS(workosOverrides), ); @@ -84,6 +84,12 @@ describe("Org domain handlers", () => { requireAdmin.pipe(Effect.provide(provide("admin"))), ); + it.effect("rejects an admin without a verified second factor", () => + Effect.gen(function* () { + expect(yield* Effect.flip(requireAdmin)).toBeInstanceOf(Forbidden); + }).pipe(Effect.provideService(OrgMemberRole, { memberRole: "admin", adminVerified: false })), + ); + it.effect("rejects a non-admin caller with Forbidden", () => Effect.gen(function* () { const error = yield* Effect.flip(requireAdmin); @@ -239,6 +245,7 @@ const workosForCaller = (deleted: string[]) => authenticateSealedSession: () => Effect.succeed({ userId: CALLER, + adminVerified: true, email: "caller@placeholder.test", organizationId: ORG, }), diff --git a/bun.lock b/bun.lock index 87589fabdd..190814528d 100644 --- a/bun.lock +++ b/bun.lock @@ -356,7 +356,7 @@ "version": "0.0.50", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.2", + "@executor-js/emulate": "0.14.3-mfa.0", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -381,6 +381,7 @@ "@vitejs/plugin-react": "catalog:", "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", + "otpauth": "9.5.2", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", @@ -1811,7 +1812,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.14.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-rUzfQFq1dO3qwzW83jL7kEikLLPXTjqLTSU9qpVdbYyqMF/Ef8YgwH+hw0tbqNEkuGFwuZKkMkDUPPfkidMamg=="], + "@executor-js/emulate": ["@executor-js/emulate@0.14.3-mfa.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "jose": "^6", "openid-client": "^6.8.4", "otpauth": "9.5.2", "picocolors": "^1.1.1", "qrcode": "1.5.4", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-XdXLM+Q5lWtfkgAqa95atZmpKAjel9A29ulJeDCgMlKwPerQJ1d8yWsAn5b3iq68Bq5HGwtjZt7ebBuwZP3dBw=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], @@ -2221,7 +2222,7 @@ "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], - "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -3521,6 +3522,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], @@ -3765,6 +3768,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -3819,6 +3824,8 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], @@ -4875,6 +4882,8 @@ "ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="], + "otpauth": ["otpauth@9.5.2", "", { "dependencies": { "@noble/hashes": "2.4.0" } }, "sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "oxc-parser": ["oxc-parser@0.121.0", "", { "dependencies": { "@oxc-project/types": "^0.121.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.121.0", "@oxc-parser/binding-android-arm64": "0.121.0", "@oxc-parser/binding-darwin-arm64": "0.121.0", "@oxc-parser/binding-darwin-x64": "0.121.0", "@oxc-parser/binding-freebsd-x64": "0.121.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.121.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.121.0", "@oxc-parser/binding-linux-arm64-gnu": "0.121.0", "@oxc-parser/binding-linux-arm64-musl": "0.121.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-musl": "0.121.0", "@oxc-parser/binding-linux-s390x-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-musl": "0.121.0", "@oxc-parser/binding-openharmony-arm64": "0.121.0", "@oxc-parser/binding-wasm32-wasi": "0.121.0", "@oxc-parser/binding-win32-arm64-msvc": "0.121.0", "@oxc-parser/binding-win32-ia32-msvc": "0.121.0", "@oxc-parser/binding-win32-x64-msvc": "0.121.0" } }, "sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg=="], @@ -4995,7 +5004,7 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], - "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -5077,6 +5086,8 @@ "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -5255,6 +5266,8 @@ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], @@ -5341,6 +5354,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -5755,6 +5770,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -5871,6 +5888,8 @@ "@better-auth/core/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@better-auth/utils/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -5961,6 +5980,8 @@ "@executor-js/emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "@executor-js/emulate/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "@executor-js/example-all-plugins/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], @@ -5969,6 +5990,8 @@ "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "@executor-js/host-selfhost/@executor-js/emulate": ["@executor-js/emulate@0.14.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-rUzfQFq1dO3qwzW83jL7kEikLLPXTjqLTSU9qpVdbYyqMF/Ef8YgwH+hw0tbqNEkuGFwuZKkMkDUPPfkidMamg=="], + "@executor-js/mcporter/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@executor-js/mcporter/rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], @@ -6003,6 +6026,8 @@ "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@jimp/js-png/pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -6213,6 +6238,8 @@ "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], @@ -6475,6 +6502,8 @@ "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "better-auth/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "better-auth/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -6741,6 +6770,8 @@ "protobufjs/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -7109,6 +7140,10 @@ "@executor-js/e2e/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@executor-js/host-selfhost/@executor-js/emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "@executor-js/host-selfhost/@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "@executor-js/mcporter/rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], "@executor-js/mcporter/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], @@ -7757,6 +7792,14 @@ "protobufjs/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "request/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -8005,6 +8048,10 @@ "ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "qrcode/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "superagent/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -8033,6 +8080,8 @@ "googleapis-common/google-auth-library/gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "temp/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], diff --git a/e2e/cloud/admin-mfa-api.test.ts b/e2e/cloud/admin-mfa-api.test.ts new file mode 100644 index 0000000000..449376014f --- /dev/null +++ b/e2e/cloud/admin-mfa-api.test.ts @@ -0,0 +1,225 @@ +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 new file mode 100644 index 0000000000..dcc1c24861 --- /dev/null +++ b/e2e/cloud/admin-mfa.test.ts @@ -0,0 +1,122 @@ +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 a04502c851..6d7b8bf634 100644 --- a/e2e/cloud/auth-hint.test.ts +++ b/e2e/cloud/auth-hint.test.ts @@ -112,6 +112,10 @@ scenario( const names = (await page.context().cookies()).map((cookie) => cookie.name); expect(names, "the hint never outlives the session").not.toContain(HINT_COOKIE); expect(names, "the session itself is gone too").not.toContain("wos-session"); + expect(names, "admin verification ends on logout").not.toContain("__Host-executor-admin-mfa"); + expect(names, "unfinished admin challenges are cleared").not.toContain( + "__Host-executor-admin-challenge", + ); }); }), ); diff --git a/e2e/cloud/mcp-browser-resume-page.test.ts b/e2e/cloud/mcp-browser-resume-page.test.ts index 5e82e5eb0d..70785e6f7e 100644 --- a/e2e/cloud/mcp-browser-resume-page.test.ts +++ b/e2e/cloud/mcp-browser-resume-page.test.ts @@ -17,6 +17,7 @@ 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); @@ -274,3 +275,77 @@ 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 7c13c8c16e..f24aff3c44 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 · a member session is denied while an admin session succeeds", + "MCP workspace writes · both member and admin tokens are denied workspace writes", { 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.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( + allowed.text, + "even an MFA-verified admin's machine token has no workspace write access", + ).toMatch(/OrgWriteDenied|administrator|admin/i); expect( (yield* adminClient.policies.list()).some((policy) => policy.pattern === pattern), - "the admin's MCP call persisted the Workspace policy", - ).toBe(true); + "the admin's denied MCP call persisted no Workspace policy", + ).toBe(false); }), cleanup, ); diff --git a/e2e/cloud/oauth-callback-unauthenticated.test.ts b/e2e/cloud/oauth-callback-unauthenticated.test.ts index e4e4df56bf..99797915c6 100644 --- a/e2e/cloud/oauth-callback-unauthenticated.test.ts +++ b/e2e/cloud/oauth-callback-unauthenticated.test.ts @@ -14,6 +14,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { verifyAdminInBrowser } from "./support/admin-mfa"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -52,101 +53,123 @@ const oauthIntegrationSpec = (oauth: { ], }) as const; -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")}`, +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", }, - }), - ); - 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, - }); - await page.waitForFunction(() => document.body.innerText.includes("Connected"), null, { - timeout: 30_000, - }); + const started = yield* client.oauth.start({ + payload: { + client: clientSlug, + clientOwner: "org", + owner, + 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 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", + const authorize = yield* Effect.promise(() => + fetch(authorizationUrl, { redirect: "manual" }), ); - 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(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")}`, + }, + }), ); - }); - }).pipe(Effect.scoped), -); + 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), + ); diff --git a/e2e/cloud/org-api-keys-console.test.ts b/e2e/cloud/org-api-keys-console.test.ts index f4b34dafb8..88e1b0594f 100644 --- a/e2e/cloud/org-api-keys-console.test.ts +++ b/e2e/cloud/org-api-keys-console.test.ts @@ -1,19 +1,4 @@ -// 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. +// Organization keys retain shared product reads. Cross-user reads require a verified browser session. import { expect } from "@effect/vitest"; import { Effect } from "effect"; @@ -29,7 +14,7 @@ declare global { } scenario( - "Admin · organization keys are minted in the console and authenticate the admin API", + "Admin · organization keys are minted in the console and authenticate shared product reads", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; @@ -83,20 +68,14 @@ scenario( .waitFor({ state: "visible", timeout: 30_000 }); }); - 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 }>; - }; + await step("The minted value reads shared data but cannot read other users", async () => { + const headers = { authorization: `Bearer ${mintedValue}` }; expect( - body.users.map((user) => user.email), - "and sees the workspace's members", - ).toContain(admin.credentials?.email); + (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, + ); }); await step("Revoke asks for confirmation, then the key stops working", async () => { @@ -113,7 +92,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/admin/users", target.baseUrl), { + const after = await fetch(new URL("/api/integrations", 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 bb3f9f187c..e68530314f 100644 --- a/e2e/cloud/org-delete.test.ts +++ b/e2e/cloud/org-delete.test.ts @@ -10,6 +10,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; import { visit, settle } from "../src/surfaces/browser"; +import { verifyAdminInBrowser } from "./support/admin-mfa"; scenario( "Organizations · an admin deletes the organization from settings", @@ -40,6 +41,7 @@ scenario( await step("Open Organization settings and find the danger zone", async () => { await visit(page, `/${slug}/org`); + await verifyAdminInBrowser(page); // The admin-only danger zone renders (a member would not see it). await page.getByText("Permanently delete this organization").waitFor(); }); diff --git a/e2e/cloud/session-gate.test.ts b/e2e/cloud/session-gate.test.ts index 7868c872af..ced114b232 100644 --- a/e2e/cloud/session-gate.test.ts +++ b/e2e/cloud/session-gate.test.ts @@ -13,6 +13,7 @@ import * as Iron from "iron-webcrypto"; import { scenario } from "../src/scenario"; import { Api, Target } from "../src/services"; import { E2E_COOKIE_PASSWORD } from "../targets/cloud"; +import { browserCookies } from "./support/admin-mfa"; /** A signed-out-style document request (what the gate keys on). */ const documentRequest = (url: URL, cookie?: string) => @@ -115,7 +116,9 @@ scenario( // waiting out a real expiry. Same sealing library + password map the WorkOS // SDK uses, so the gate can't tell this seal from one the SDK minted. const withTamperedAccessToken = async (sessionCookie: string): Promise => { - const sealed = sessionCookie.slice("wos-session=".length).replace(/~\d$/, ""); + const cookie = browserCookies(sessionCookie).find((entry) => entry.name === "wos-session"); + if (!cookie) throw new Error("Test identity has no WorkOS session"); + const sealed = cookie.value.replace(/~\d$/, ""); const session = (await Iron.unseal(sealed, { "1": E2E_COOKIE_PASSWORD }, Iron.defaults)) as { accessToken: string; }; diff --git a/e2e/cloud/support/admin-mfa.ts b/e2e/cloud/support/admin-mfa.ts new file mode 100644 index 0000000000..826fec73b6 --- /dev/null +++ b/e2e/cloud/support/admin-mfa.ts @@ -0,0 +1,119 @@ +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 cb8b0d6cf7..62ed040c36 100644 --- a/e2e/cloud/support/session.ts +++ b/e2e/cloud/support/session.ts @@ -17,6 +17,7 @@ // // `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module // is never collected as a suite. +import { browserCookies } from "./admin-mfa"; import { Effect } from "effect"; import type { Identity, Target as TargetShape } from "../../src/target"; @@ -53,7 +54,7 @@ export const forBrowser = (identity: Identity): Identity => { if (separator < 0) throw new Error("identity carries no session cookie"); return { ...identity, - cookies: [{ name: cookie.slice(0, separator), value: cookie.slice(separator + 1) }], + cookies: browserCookies(cookie), }; }; @@ -98,7 +99,15 @@ export const withRefreshedSession = ( .find((header) => header.startsWith("wos-session=")) ?.split(";")[0]; if (!refreshed) throw new Error("response did not refresh the session cookie"); - return { ...identity, headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector } }; + const cookies = browserCookies(cookieOf(identity)).filter( + (cookie) => cookie.name !== "wos-session", + ); + const cookie = [refreshed, ...cookies.map(({ name, value }) => `${name}=${value}`)].join("; "); + return { + ...identity, + headers: { cookie, [ORG_SELECTOR_HEADER]: orgSelector }, + cookies: browserCookies(cookie), + }; }; /** The org selector this identity's requests carry — the same header the web diff --git a/e2e/cloud/workspace-write-permissions.test.ts b/e2e/cloud/workspace-write-permissions.test.ts index 955e175f3a..5579b44741 100644 --- a/e2e/cloud/workspace-write-permissions.test.ts +++ b/e2e/cloud/workspace-write-permissions.test.ts @@ -3,6 +3,7 @@ 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( @@ -16,3 +17,14 @@ 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 10f9a8e40f..a1ef204f65 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.2", + "@executor-js/emulate": "0.14.3-mfa.0", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -48,6 +48,7 @@ "@vitejs/plugin-react": "catalog:", "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", + "otpauth": "9.5.2", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts index 6510f77d9a..de7275fcf8 100644 --- a/e2e/src/surfaces/browser.ts +++ b/e2e/src/surfaces/browser.ts @@ -202,10 +202,13 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface await installRecordingUrlBar(context); if (identity.cookies?.length) { await context.addCookies( - identity.cookies.map((cookie) => ({ - ...cookie, - url: target.baseUrl, - })), + identity.cookies.map((cookie) => { + const source = new URL(target.baseUrl); + // Chromium validates __Host- cookies against their source scheme, + // even on localhost where Secure cookies work over HTTP. + if (cookie.secure) source.protocol = "https:"; + return { ...cookie, url: source.href }; + }), ); } const page = await context.newPage(); diff --git a/e2e/src/target.ts b/e2e/src/target.ts index a410490537..0ad050c228 100644 --- a/e2e/src/target.ts +++ b/e2e/src/target.ts @@ -21,9 +21,17 @@ export interface Identity { /** Headers that authenticate API requests (e.g. a session cookie). */ readonly headers?: Record; /** Cookies to inject into a browser context for a logged-in page. */ - readonly cookies?: ReadonlyArray<{ readonly name: string; readonly value: string }>; + readonly cookies?: ReadonlyArray<{ + readonly name: string; + readonly value: string; + readonly secure?: boolean; + }>; /** Credentials for surfaces that sign in themselves (Better Auth, OAuth consent). */ - readonly credentials?: { readonly email: string; readonly password: string }; + readonly credentials?: { + readonly email: string; + readonly password: string; + readonly totpSecret?: string; + }; } export interface Target { @@ -37,7 +45,10 @@ export interface Target { * `org: false` yields an identity with no active organization (for flows * that create one, like onboarding / billing limits). */ - readonly newIdentity: (options?: { readonly org?: boolean }) => Effect.Effect; + readonly newIdentity: (options?: { + readonly org?: boolean; + readonly adminMfa?: boolean; + }) => Effect.Effect; /** Headless OAuth consent for the MCP surface, when "mcp-oauth" is supported. */ readonly mcpConsent?: ( identity: Identity, diff --git a/e2e/targets/cloud.ts b/e2e/targets/cloud.ts index b5d360a2ad..1bed3701bf 100644 --- a/e2e/targets/cloud.ts +++ b/e2e/targets/cloud.ts @@ -10,6 +10,7 @@ import { Effect } from "effect"; import { connectEmulator } from "@executor-js/emulate"; +import { verifyAdmin } from "../cloud/support/admin-mfa"; import { e2ePort } from "../src/ports"; import type { Identity, Target } from "../src/target"; @@ -71,7 +72,7 @@ export const cloudTarget = (): Target => ({ }); await workos.seed({ oauth: { default_access_token_ttl_seconds: seconds } }); }), - newIdentity: ({ org = true } = {}) => + newIdentity: ({ org = true, adminMfa = true } = {}) => Effect.promise(async (): Promise => { const label = `user-${randomUUID().slice(0, 8)}`; const email = `${label}@e2e.test`; @@ -96,7 +97,7 @@ export const cloudTarget = (): Target => ({ orgSlug = ((await response.json()) as { slug?: string }).slug ?? null; } const [name, value] = session.split(/=(.*)/s); - return { + const identity: Identity = { label: email, // The org selector header rides along exactly as the web client sends // it from the console URL's slug: org-scoped API reads fail closed @@ -108,6 +109,9 @@ export const cloudTarget = (): Target => ({ cookies: [{ name: name!, value: value! }], credentials: { email, password: "emulated" }, }; + return org && adminMfa + ? await Effect.runPromise(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 f30c5d14e7..5b995819fc 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("rechecks workspace authorization before persisting OAuth callback tokens", () => + it.effect("keeps the OAuth code redeemable until workspace access is restored", () => Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: [] }); const { admin, member } = yield* setup(); @@ -353,6 +353,14 @@ 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 da86dbe810..1d2cde67eb 100644 --- a/packages/core/sdk/src/testing.ts +++ b/packages/core/sdk/src/testing.ts @@ -1,7 +1,9 @@ +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, @@ -93,6 +95,17 @@ 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< @@ -102,7 +115,7 @@ const makeTestHttpServer = ( > => Effect.gen(function* () { const context = yield* Layer.build( - Layer.fresh(serverLayer.pipe(Layer.provideMerge(NodeHttpServer.layerTest))), + Layer.fresh(serverLayer.pipe(Layer.provideMerge(testServerLayer))), ).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 0380e389f0..6e0fbf7346 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -1,3 +1,4 @@ +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"; @@ -355,7 +356,9 @@ const serveOAuthTestHttpApp = ( Layer.fresh( HttpServer.serve( HttpServerRequest.HttpServerRequest.asEffect().pipe(Effect.flatMap(handler)), - ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), + ).pipe( + Layer.provideMerge(NodeHttpServer.layer(createServer, { port: 0, host: "127.0.0.1" })), + ), ), ).pipe(Effect.mapError((address) => new OAuthTestServerAddressError({ address }))); const server = Context.get(context, HttpServer.HttpServer);