diff --git a/.changeset/quiet-admin-unlock.md b/.changeset/quiet-admin-unlock.md new file mode 100644 index 0000000000..dd6aecafac --- /dev/null +++ b/.changeset/quiet-admin-unlock.md @@ -0,0 +1,6 @@ +--- +"@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 d9aaf70d27..898fe3a178 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -13,6 +13,7 @@ import { UserStoreService } from "../auth/context"; import { WorkOsMirror } from "../auth/workos-mirror"; import { sessionFromSealed, type Session } from "../auth/middleware"; import { WorkOSClient } from "../auth/workos"; +import { ADMIN_MFA_COOKIE } from "../auth/admin-mfa-proof"; import { AutumnService } from "../extensions/billing/service"; import { DbService } from "../db/db"; import { AccountCaller, workosAccountProvider } from "./workos-account-service"; @@ -69,7 +70,7 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ const request = yield* HttpServerRequest.HttpServerRequest; const cookieValue = request.cookies["wos-session"] ?? ""; const resolved = yield* workos - .authenticateSealedSession(cookieValue) + .authenticateSealedSession(cookieValue, request.cookies[ADMIN_MFA_COOKIE]) .pipe(Effect.orElseSucceed(() => null)); // The account API never re-sets the cookie, so the fallback sealed // session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie). diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts index 94f5aed511..5ea7de5707 100644 --- a/apps/cloud/src/account/workos-account-service.ts +++ b/apps/cloud/src/account/workos-account-service.ts @@ -144,7 +144,15 @@ export const workosAccountProvider: Layer.Layer< // moments ago is denied as soon as the write-through or the Events // reconciler has landed the change. const requireAdmin = (org: { readonly memberRole: "admin" | "member" }) => - org.memberRole === "admin" ? Effect.void : Effect.fail(new AccountForbidden()); + Effect.gen(function* () { + if (org.memberRole !== "admin") return yield* new AccountForbidden(); + const session = yield* requireSession(); + if (session.adminVerified !== true) { + return yield* new AccountForbidden({ + message: "Verify your identity to use organization admin settings.", + }); + } + }); // Ownership check so an admin can't mutate a membership id from another // org: the id must name a row the mirror holds for THIS org (any status — diff --git a/apps/cloud/src/admin/admin-users-api.ts b/apps/cloud/src/admin/admin-users-api.ts index 0c77c03a93..f9132d9c6a 100644 --- a/apps/cloud/src/admin/admin-users-api.ts +++ b/apps/cloud/src/admin/admin-users-api.ts @@ -2,19 +2,9 @@ // Cloud admin users API — the shared, provider-neutral `AdminUsersHandlers` // backed by a WorkOS-authorized platform view, mounted at `/api/admin/users*`. // -// 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. +// 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. // // The executor is built by `makePlatformExecutor` — `{ tenant, subject: // undefined, platformView: true }` — so the reads are tenant-wide and read-only @@ -54,10 +44,8 @@ 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"; @@ -66,9 +54,7 @@ import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; /** * Resolve the tenant this request may read, or fail with the neutral 401/403. * - * 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. + * Returns only the authorized organization id so admin reads remain tenant-scoped. * Exported for its test only. */ export const authorizeTenant = ( @@ -76,27 +62,11 @@ export const authorizeTenant = ( ): Effect.Effect< string, AdminUsersUnauthorized | AdminUsersForbidden, - WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror + WorkOSClient | UserStoreService | MemberDirectory | WorkOsMirror > => Effect.gen(function* () { - // (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(); - } + if (request.headers.has("authorization")) 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) @@ -115,6 +85,7 @@ export const authorizeTenant = ( ); if (!org) return yield* new AdminUsersForbidden(); if (org.memberRole !== "admin") return yield* new AdminUsersForbidden(); + if (session.adminVerified !== true) return yield* new AdminUsersForbidden(); return org.id; }); @@ -134,7 +105,6 @@ 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 8c80825ef2..5851158f26 100644 --- a/apps/cloud/src/api/router.ts +++ b/apps/cloud/src/api/router.ts @@ -11,6 +11,7 @@ import { UserStoreService } from "../auth/context"; import { WorkOsMirror } from "../auth/workos-mirror"; import { DbService } from "../db/db"; import { makeAccountApiLive } from "../account/account-api"; +import { AdminMfaRoutes } from "../auth/admin-mfa-routes"; import { AutumnRoutesLive } from "../extensions/billing/route"; import { CloudDocsLive } from "../extensions/docs"; @@ -41,6 +42,7 @@ export const makeApiLive = ( Layer.provide(requestScopedMiddleware(requestScopedLive).layer), ); return Layer.mergeAll( + AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(requestScopedLive).layer)), makeNonProtectedApiLive(requestScopedLive), makeOrgApiLive(requestScopedLive), makeAccountApiLive(requestScopedLive), diff --git a/apps/cloud/src/auth/admin-mfa-proof.ts b/apps/cloud/src/auth/admin-mfa-proof.ts new file mode 100644 index 0000000000..c174ae59a2 --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-proof.ts @@ -0,0 +1,89 @@ +import { Data, Effect, Option, Schema } from "effect"; +import { SignJWT, jwtVerify } from "jose"; + +/** HttpOnly cookies used only for the administrative verification flow. */ +export const ADMIN_MFA_COOKIE = "__Host-executor-admin-mfa"; +/** The pending challenge is bound to the same user and WorkOS session. */ +export const ADMIN_MFA_CHALLENGE_COOKIE = "__Host-executor-admin-challenge"; +/** Administrative verification expires after fifteen minutes. */ +export const ADMIN_MFA_TTL_SECONDS = 15 * 60; + +/** A verified WorkOS session, supplied by the authentication adapter. */ +export interface AdminMfaIdentity { + readonly userId: string; + readonly sessionId: string; +} + +const Proof = Schema.Struct({ + factorId: Schema.String, + challengeId: Schema.String, + mode: Schema.Literals(["enroll", "challenge"]), + exp: Schema.Number, +}); +const decodeProof = Schema.decodeUnknownOption(Proof); + +/** Signing failures are server failures; invalid input cookies are simply refused. */ +export class AdminMfaProofError extends Data.TaggedError("AdminMfaProofError")<{ + readonly cause: unknown; +}> {} + +type Purpose = "challenge" | "verified"; +const issuer = (purpose: Purpose) => `executor:admin-mfa:${purpose}`; +const key = (secret: string) => new TextEncoder().encode(secret); + +/** Sign a purpose-specific, session-bound proof with an explicit expiration. */ +export const signAdminMfaProof = ( + secret: string, + identity: AdminMfaIdentity, + purpose: Purpose, + proof: typeof Proof.Type, + now: number, +) => + Effect.tryPromise({ + try: () => + new SignJWT({ factorId: proof.factorId, challengeId: proof.challengeId, mode: proof.mode }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer(issuer(purpose)) + .setSubject(identity.userId) + .setAudience(identity.sessionId) + .setIssuedAt(Math.floor(now / 1000)) + .setExpirationTime(proof.exp) + .sign(key(secret)), + catch: (cause) => new AdminMfaProofError({ cause }), + }); + +/** Reject expired, tampered, cross-user, cross-session, and wrong-purpose proofs. */ +export const readAdminMfaProof = ( + secret: string, + identity: AdminMfaIdentity, + purpose: Purpose, + token: string | undefined, + now: number, +) => { + if (!token) return Effect.succeed(null); + return Effect.tryPromise({ + try: () => + jwtVerify(token, key(secret), { + algorithms: ["HS256"], + issuer: issuer(purpose), + subject: identity.userId, + audience: identity.sessionId, + requiredClaims: ["exp", "iat", "sub", "aud"], + maxTokenAge: purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS, + currentDate: new Date(now), + }), + catch: (cause) => new AdminMfaProofError({ cause }), + }).pipe( + Effect.map(({ payload }) => { + const maxAge = purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS; + if ( + typeof payload.iat !== "number" || + typeof payload.exp !== "number" || + payload.exp > payload.iat + maxAge + ) + return null; + return Option.getOrNull(decodeProof(payload)); + }), + Effect.catchTag("AdminMfaProofError", () => Effect.succeed(null)), + ); +}; diff --git a/apps/cloud/src/auth/admin-mfa-routes.ts b/apps/cloud/src/auth/admin-mfa-routes.ts new file mode 100644 index 0000000000..523ecb1316 --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-routes.ts @@ -0,0 +1,245 @@ +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 6453a0547f..c62934948d 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -15,6 +15,7 @@ import { import { MemberDirectory, NoOrganization } from "@executor-js/api/server"; // Pure constants/codec module (no React) — safe in the backend graph. import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint"; +import { ADMIN_MFA_COOKIE, ADMIN_MFA_CHALLENGE_COOKIE } from "./admin-mfa-proof"; import { SessionContext, SessionCookies } from "./middleware"; import { encodeLoginState, decodeLoginState } from "./login-state"; import { safeReturnTo } from "./return-to"; @@ -353,6 +354,9 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( "wos-session", ), AUTH_HINT_COOKIE, + ).pipe( + (response) => deleteResponseCookie(response, ADMIN_MFA_COOKIE), + (response) => deleteResponseCookie(response, ADMIN_MFA_CHALLENGE_COOKIE), ); }), ) @@ -562,7 +566,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // not an admin) and reported its role, so the gate is that one // value: a member removed or demoted moments ago is denied once the // write-through or the Events reconciler has landed the change. - if (session.memberRole !== "admin") { + if (session.memberRole !== "admin" || session.adminVerified !== true) { return yield* new OrganizationDeletionForbidden(); } @@ -829,7 +833,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( { accountId: owner.accountId, organizationId: owner.organizationId, - orgRole: owner.memberRole, + orgRole: owner.adminVerified === true ? owner.memberRole : "member", }, { action: payload.action, diff --git a/apps/cloud/src/auth/middleware-live.ts b/apps/cloud/src/auth/middleware-live.ts index 16ac3a4ff6..1b30675669 100644 --- a/apps/cloud/src/auth/middleware-live.ts +++ b/apps/cloud/src/auth/middleware-live.ts @@ -4,7 +4,8 @@ // --------------------------------------------------------------------------- import { Effect, Layer, Redacted } from "effect"; -import { HttpServerResponse } from "effect/unstable/http"; +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { ADMIN_MFA_COOKIE } from "./admin-mfa-proof"; import { AuthContext, NoOrganization, Unauthorized } from "@executor-js/api/server"; @@ -27,7 +28,10 @@ export const SessionAuthLive = Layer.effect( cookie: (httpEffect, { credential }) => Effect.gen(function* () { const result = yield* workos - .authenticateSealedSession(Redacted.value(credential)) + .authenticateSealedSession( + Redacted.value(credential), + (yield* HttpServerRequest.HttpServerRequest).cookies[ADMIN_MFA_COOKIE], + ) .pipe(Effect.orElseSucceed(() => null)); if (!result) { diff --git a/apps/cloud/src/auth/middleware.ts b/apps/cloud/src/auth/middleware.ts index cecd1a64f4..8f5ea36e13 100644 --- a/apps/cloud/src/auth/middleware.ts +++ b/apps/cloud/src/auth/middleware.ts @@ -38,6 +38,8 @@ export type Session = { readonly organizationId: string | null; readonly sealedSession: string; readonly refreshedSession: string | null; + /** True only while a verified second factor remains bound to this session. */ + readonly adminVerified?: boolean; }; export class SessionContext extends Context.Service()( @@ -69,6 +71,7 @@ export class SessionCookies extends Context.Service => { + 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 7165a93042..b45173a1da 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: org.memberRole, + orgRole: "member", } 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. * - * The `/admin/*` mount turns this into an executor with `{ tenant: - * organizationId, subject: undefined, platformView: true }`. + * This credential grants shared catalog reads only. Cross-user admin reads + * require an MFA-verified browser session. */ export interface PlatformAuth { readonly kind: "platform"; @@ -272,7 +272,7 @@ export const resolveBearerAuth = ( avatarUrl: null, roles: [], orgRoleModel: "organization", - orgRole: org.memberRole, + orgRole: "member", } satisfies Principal; }); @@ -354,7 +354,7 @@ export const resolveSessionPrincipal = (request: Request) => avatarUrl: session.avatarUrl ?? null, roles: [], orgRoleModel: "organization", - orgRole: org.memberRole, + orgRole: session.adminVerified === true ? org.memberRole : "member", } satisfies Principal; }); diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 918a7e8556..189f01704a 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -16,6 +16,7 @@ import { decodeJwt, jwtVerify } from "jose"; import { workosAccessTokenOptions } from "./access-token-options"; import { JWKSInvalid, JWKSNoMatchingKey, JWKSTimeout } from "jose/errors"; import { parseCookie } from "./cookies"; +import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "./admin-mfa-proof"; import { createCachedRemoteJWKSet, type CachedRemoteJWKSet } from "./jwks-cache"; import { ServiceAdapterError, @@ -431,7 +432,18 @@ const make = Effect.gen(function* () { tryPromiseService(() => fn(workos)), ); - const authenticateSealedSession = (sessionData: string) => + // MFA SDK errors can contain response details. Keep only the status before + // logging, so enrollment secrets and submitted codes cannot enter a cause. + const useMfa = (op: string, fn: (wos: WorkOS) => Promise) => + tryPromiseService(() => fn(workos)).pipe( + Effect.mapError(workosErrorFromFailure), + Effect.tapError((error) => + Effect.logWarning(`workos.${op} failed`, { status: error.status }), + ), + Effect.withSpan(`workos.${op}`), + ); + + const authenticateSealedSession = (sessionData: string, adminProof?: string) => Effect.gen(function* () { if (!sessionData) return null; @@ -447,6 +459,16 @@ const make = Effect.gen(function* () { ); if (isLocalSessionValid(local)) { + const proof = yield* readAdminMfaProof( + cookiePassword, + { + userId: local.session.user.id, + sessionId: local.sessionId, + }, + "verified", + adminProof, + Date.now(), + ); return { userId: local.session.user.id, email: local.session.user.email, @@ -455,6 +477,8 @@ const make = Effect.gen(function* () { avatarUrl: local.session.user.profilePictureUrl, organizationId: local.organizationId, sessionId: local.sessionId, + adminVerified: proof !== null, + adminVerificationExpiresAt: proof?.exp ?? null, refreshedSession: undefined as string | undefined, }; } @@ -469,6 +493,17 @@ const make = Effect.gen(function* () { if (!refreshed.authenticated || !("sealedSession" in refreshed) || !refreshed.sealedSession) return null; + const proof = yield* readAdminMfaProof( + cookiePassword, + { + userId: refreshed.user.id, + sessionId: refreshed.sessionId, + }, + "verified", + adminProof, + Date.now(), + ); + return { userId: refreshed.user.id, email: refreshed.user.email, @@ -477,11 +512,38 @@ const make = Effect.gen(function* () { avatarUrl: refreshed.user.profilePictureUrl, organizationId: refreshed.organizationId, sessionId: refreshed.sessionId, + adminVerified: proof !== null, + adminVerificationExpiresAt: proof?.exp ?? null, refreshedSession: refreshed.sealedSession, }; }); return { + /** List factors belonging to this user; callers cannot supply another user's factor. */ + listMfaFactors: (userId: string) => + useMfa("userManagement.listAuthFactors", (wos) => + wos.userManagement + .listAuthFactors({ userId, limit: 100 }) + .then((page) => page.autoPagination()), + ), + /** Begin AuthKit's user-bound TOTP enrollment. The secret is returned only to that user. */ + enrollMfa: (userId: string, email: string) => + useMfa("userManagement.enrollAuthFactor", (wos) => + wos.userManagement.enrollAuthFactor({ + userId, + type: "totp", + totpIssuer: "Executor", + totpUser: email, + }), + ), + /** Challenge an already resolved factor. */ + challengeMfa: (authenticationFactorId: string) => + useMfa("mfa.challengeFactor", (wos) => wos.mfa.challengeFactor({ authenticationFactorId })), + /** Verify a TOTP code with WorkOS; never log the code or factor secret. */ + verifyMfa: (authenticationChallengeId: string, code: string) => + useMfa("mfa.verifyChallenge", (wos) => + wos.mfa.verifyChallenge({ authenticationChallengeId, code }), + ), getAuthorizationUrl: (redirectUri: string, state?: string) => workos.userManagement.getAuthorizationUrl({ provider: "authkit", @@ -595,7 +657,10 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const sessionData = parseCookie(request.headers.get("cookie"), COOKIE_NAME); if (!sessionData) return null; - return yield* authenticateSealedSession(sessionData); + return yield* authenticateSealedSession( + sessionData, + parseCookie(request.headers.get("cookie"), ADMIN_MFA_COOKIE) ?? undefined, + ); }), /** diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 715991f394..017570cf1a 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -5,6 +5,12 @@ declare global { namespace Cloudflare { interface Env { + /** TOTP enrollment and verification attempts; absence refuses verification. */ + ADMIN_MFA_RATE_LIMITER?: { + readonly limit: (options: { + readonly key: string; + }) => Promise<{ readonly success: boolean }>; + }; // Observability // Worker version metadata binding (wrangler.jsonc `version_metadata`). // Optional so test workers and local setups without the binding still diff --git a/apps/cloud/src/extensions/billing/route.ts b/apps/cloud/src/extensions/billing/route.ts index e353c6c2be..8269727272 100644 --- a/apps/cloud/src/extensions/billing/route.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -87,8 +87,19 @@ 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 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 f1c4389fe7..c710597884 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -38,6 +38,7 @@ import { NonProtectedApi, } from "../auth/handlers"; import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; +import { AdminMfaRoutes } from "../auth/admin-mfa-routes"; import { SessionAuthLive } from "../auth/middleware-live"; import { runWorkOsEventsSync } from "../auth/workos-events-runner"; import { makeWorkOsWebhookRoute } from "../auth/workos-webhook"; @@ -115,7 +116,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 - // org key (or an admin session) and builds a subject-less platform view. + // verified admin session and builds a subject-less platform view. const AdminUsersRoutes = makeCloudAdminUsersRoutes(rsLive, { router: apiPrefixedRouter, }); @@ -131,6 +132,7 @@ 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 b05685af58..7ab1d5c683 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: organization.memberRole, + orgRole: "member", email: "", name: null, avatarUrl: null, diff --git a/apps/cloud/src/org/api.ts b/apps/cloud/src/org/api.ts index 55cfabfd31..47587ed852 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, + error: [WorkOSError, Forbidden], }), ) .add( diff --git a/apps/cloud/src/org/auth-middleware.ts b/apps/cloud/src/org/auth-middleware.ts index 9c61236f3b..4be83a5dcb 100644 --- a/apps/cloud/src/org/auth-middleware.ts +++ b/apps/cloud/src/org/auth-middleware.ts @@ -12,6 +12,7 @@ import { sessionFromSealed } from "../auth/middleware"; import { WorkOsMirror } from "../auth/workos-mirror"; import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; import { WorkOSClient } from "../auth/workos"; +import { ADMIN_MFA_COOKIE } from "../auth/admin-mfa-proof"; import { DbService } from "../db/db"; const unauthorized = () => @@ -41,7 +42,7 @@ const noOrganization = () => */ export class OrgMemberRole extends Context.Service< OrgMemberRole, - { readonly memberRole: "admin" | "member" } + { readonly memberRole: "admin" | "member"; readonly adminVerified?: boolean } >()("@executor-js/cloud/OrgMemberRole") {} const OrgAuthMiddleware = HttpRouter.middleware<{ @@ -55,7 +56,7 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ const request = yield* HttpServerRequest.HttpServerRequest; const cookieValue = request.cookies["wos-session"] ?? ""; const result = yield* workos - .authenticateSealedSession(cookieValue) + .authenticateSealedSession(cookieValue, request.cookies[ADMIN_MFA_COOKIE]) .pipe(Effect.orElseSucceed(() => null)); if (!result) return unauthorized(); @@ -84,7 +85,10 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ return yield* Effect.provideContext( httpEffect, Context.make(AuthContext, auth).pipe( - Context.add(OrgMemberRole, { memberRole: org.memberRole }), + Context.add(OrgMemberRole, { + memberRole: org.memberRole, + adminVerified: result.adminVerified, + }), ), ); }).pipe(Effect.provideContext(captured)); diff --git a/apps/cloud/src/org/handlers.ts b/apps/cloud/src/org/handlers.ts index 64c9f329b2..81415c5b25 100644 --- a/apps/cloud/src/org/handlers.ts +++ b/apps/cloud/src/org/handlers.ts @@ -29,8 +29,8 @@ import { OrgMemberRole } from "./auth-middleware"; * with `Forbidden` for a member. Exported for its test only. */ export const requireAdmin = Effect.gen(function* () { - const { memberRole } = yield* OrgMemberRole; - if (memberRole !== "admin") return yield* new Forbidden(); + const { memberRole, adminVerified } = yield* OrgMemberRole; + if (memberRole !== "admin" || adminVerified !== true) return yield* new Forbidden(); }); // Target-ownership check — independent of caller privilege. `requireAdmin` @@ -58,6 +58,7 @@ 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 b686410175..b796caa6ac 100644 --- a/apps/cloud/src/routes/app/api-keys.tsx +++ b/apps/cloud/src/routes/app/api-keys.tsx @@ -1,6 +1,8 @@ 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 @@ -10,5 +12,13 @@ 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 aaadc1a521..eff7999719 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -1,3 +1,4 @@ +import { oauthAdminVerificationResponse } from "./auth/oauth-admin-verification"; import { createMiddleware, createStart } from "@tanstack/react-start"; import { decodeOAuthCallbackState } from "@executor-js/sdk/shared"; @@ -91,7 +92,10 @@ const appRequestMiddleware = createMiddleware({ type: "request" }).server( if (isAppOwnedPath(pathname)) { const scopedRequest = pathname === OAUTH_CALLBACK_PATH ? oauthCallbackOrgScopedRequest(request) : request; - return (await getApp()).handler(prepareMcpOrgScope(scopedRequest)); + const response = await (await getApp()).handler(prepareMcpOrgScope(scopedRequest)); + return pathname === OAUTH_CALLBACK_PATH + ? oauthAdminVerificationResponse(request, response) + : response; } return next(); }, diff --git a/apps/cloud/src/web/components/admin-verification.tsx b/apps/cloud/src/web/components/admin-verification.tsx new file mode 100644 index 0000000000..3d510be3d4 --- /dev/null +++ b/apps/cloud/src/web/components/admin-verification.tsx @@ -0,0 +1,307 @@ +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 99a60178df..767b26ee0b 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { Outlet, 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"; @@ -6,6 +7,7 @@ import { trackEvent } from "@executor-js/react/api/analytics"; import { AUTH_PATHS } from "../auth/api"; import { OrgMenuSlot } from "./components/org-menu-slot"; import { SupportSlot } from "./components/support-slot"; +import { AdminVerification } from "./components/admin-verification"; // --------------------------------------------------------------------------- // Cloud shell — the SHARED multiplayer shell, identical to self-host, with @@ -47,13 +49,31 @@ 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} + content={ + props.content ?? + (adminPage ? ( + + + + ) : ( + <> + + + + )) + } /> ); } diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 6d92064589..4ee8b7a2b4 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -28,6 +28,13 @@ "observability": { "enabled": true, }, + "ratelimits": [ + { + "name": "ADMIN_MFA_RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 5, "period": 60 }, + }, + ], // Script-level logpush feeds the account's workers_trace_events Logpush job // (invocation logs, outcomes like exceededMemory, console output) into // Axiom. Pinned here because the setting lives on the script: a deploy that diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index eb4b1f939b..9e7eea17f4 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -209,6 +209,7 @@ 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 @@ -223,6 +224,11 @@ 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), ), @@ -231,7 +237,15 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler toErrorMessage: toPopupErrorMessage, channelName: OAUTH_POPUP_CHANNEL, }); - return HttpServerResponse.html(html); + // 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; }), ), ), diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 7d8d7d46ea..de49a7352e 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -2207,6 +2207,10 @@ 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 cebd2194b7..37a6e6f0e1 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 -// admin API (`/api/admin/*`). A separate section rather than rows in the table +// shared product API. 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,9 +456,8 @@ function OrgApiKeysSectionBody() {

Organization 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. + 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.