diff --git a/.changeset/minimal-key-verification.md b/.changeset/minimal-key-verification.md new file mode 100644 index 0000000000..7e388c95fc --- /dev/null +++ b/.changeset/minimal-key-verification.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +Require a second factor to manage organization API keys in the console. Issued keys and other product flows keep their existing access. diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts index d9aaf70d27..90ceb29a85 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -1,3 +1,5 @@ +import { env } from "cloudflare:workers"; +import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "../auth/admin-mfa-proof"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { Effect, Layer } from "effect"; @@ -75,6 +77,16 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ // session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie). const session: Session | null = resolved ? sessionFromSealed(resolved, "") : null; + const proof = resolved + ? yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId: resolved.userId, sessionId: resolved.sessionId }, + "verified", + request.cookies[ADMIN_MFA_COOKIE], + Date.now(), + ) + : null; + // Built inside the request body so the WorkOS account service closes // over the per-request `UserStoreService` (postgres socket) supplied by // the combined request-scoped layer. `local` keeps that promise: the @@ -84,7 +96,7 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ AccountProvider.asEffect(), workosAccountProvider.pipe( Layer.provide(ApiKeyService.WorkOS), - Layer.provide(Layer.succeed(AccountCaller)({ session })), + Layer.provide(Layer.succeed(AccountCaller)({ session, adminVerified: proof !== null })), ), { local: true }, ); diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts index 94f5aed511..93d81c78f7 100644 --- a/apps/cloud/src/account/workos-account-service.ts +++ b/apps/cloud/src/account/workos-account-service.ts @@ -31,7 +31,7 @@ import { // the same `WorkOSClient.authenticateSealedSession` the rest of cloud uses. export class AccountCaller extends Context.Service< AccountCaller, - { readonly session: Session | null } + { readonly session: Session | null; readonly adminVerified?: boolean } >()("@executor-js/cloud/AccountCaller") {} // --------------------------------------------------------------------------- @@ -146,6 +146,18 @@ export const workosAccountProvider: Layer.Layer< const requireAdmin = (org: { readonly memberRole: "admin" | "member" }) => org.memberRole === "admin" ? Effect.void : Effect.fail(new AccountForbidden()); + // Only organization key management requires an interactive second factor. + // Using issued keys and all other admin operations retain their existing contract. + const requireKeyManagement = (org: { readonly memberRole: "admin" | "member" }) => + Effect.gen(function* () { + yield* requireAdmin(org); + if (caller.adminVerified !== true) { + return yield* new AccountForbidden({ + message: "Verify your identity to manage organization API keys.", + }); + } + }); + // Ownership check so an admin can't mutate a membership id from another // org: the id must name a row the mirror holds for THIS org (any status — // revoking a pending invite is a delete too). One point read on the @@ -298,7 +310,7 @@ export const workosAccountProvider: Layer.Layer< listOrgApiKeys: (headers) => Effect.gen(function* () { const { org } = yield* requireOrganization(headers); - yield* requireAdmin(org); + yield* requireKeyManagement(org); const keys = yield* apiKeys .listOrgKeys({ organizationId: org.id }) .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); @@ -308,7 +320,7 @@ export const workosAccountProvider: Layer.Layer< createOrgApiKey: (headers, name) => Effect.gen(function* () { const { org } = yield* requireOrganization(headers); - yield* requireAdmin(org); + yield* requireKeyManagement(org); const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH); if (!trimmed) { return yield* new AccountError({ @@ -330,7 +342,7 @@ export const workosAccountProvider: Layer.Layer< revokeOrgApiKey: (headers, apiKeyId) => Effect.gen(function* () { const { org } = yield* requireOrganization(headers); - yield* requireAdmin(org); + yield* requireKeyManagement(org); yield* apiKeys.revokeOrgKey({ organizationId: org.id, keyId: apiKeyId }).pipe( Effect.catchTag("ApiKeyManagementError", toAccountError), Effect.catchTag("OrgApiKeyNotFound", () => 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..5048af981a --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-routes.ts @@ -0,0 +1,250 @@ +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") { + const proof = yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId: session.userId, sessionId: session.sessionId }, + "verified", + request.cookies[ADMIN_MFA_COOKIE], + yield* Clock.currentTimeMillis, + ); + return json( + org.memberRole !== "admin" + ? { state: "member" } + : proof !== null + ? { state: "verified", expiresAt: proof?.exp } + : { 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( + HttpServerResponse.setCookieUnsafe(ADMIN_MFA_COOKIE, token, { + ...cookieOptions, + sameSite: "strict", + }), + HttpServerResponse.setCookieUnsafe(ADMIN_MFA_CHALLENGE_COOKIE, "", { + ...cookieOptions, + maxAge: Duration.seconds(0), + }), + ); + }).pipe( + Effect.catch(() => + Effect.succeed( + json({ message: "Verification is temporarily unavailable. Try again." }, 503), + ), + ), + ); + // Refresh tokens rotate once. Persist the new sealed session even when + // verification is refused, so the next request can still authenticate. + return session.refreshedSession + ? HttpServerResponse.setCookieUnsafe(response, "wos-session", session.refreshedSession, { + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: Duration.days(7), + }) + : response; + }).pipe( + Effect.catch(() => + Effect.succeed(json({ message: "Verification is temporarily unavailable. Try again." }, 503)), + ), + ); + +/** Session-bound TOTP verification routes. Mount with the normal request-scoped directory. */ +export const AdminMfaRoutes = Layer.mergeAll( + HttpRouter.add("GET", "/api/auth/admin-mfa", handler("status")), + HttpRouter.add("POST", "/api/auth/admin-mfa/start", handler("start")), + HttpRouter.add("POST", "/api/auth/admin-mfa/verify", handler("verify")), + HttpRouter.add("POST", "/api/auth/admin-mfa/cancel", handler("cancel")), + HttpRouter.add("POST", "/api/auth/admin-mfa/lock", handler("lock")), +); diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 918a7e8556..af37f69218 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -431,6 +431,17 @@ const make = Effect.gen(function* () { tryPromiseService(() => fn(workos)), ); + // MFA SDK errors can contain response details. Keep only the status before + // logging, so enrollment secrets and submitted codes cannot enter a cause. + const useMfa = (op: string, fn: (wos: WorkOS) => Promise) => + tryPromiseService(() => fn(workos)).pipe( + Effect.mapError(workosErrorFromFailure), + Effect.tapError((error) => + Effect.logWarning(`workos.${op} failed`, { status: error.status }), + ), + Effect.withSpan(`workos.${op}`), + ); + const authenticateSealedSession = (sessionData: string) => Effect.gen(function* () { if (!sessionData) return null; @@ -482,6 +493,31 @@ const make = Effect.gen(function* () { }); return { + /** List factors belonging to this user; callers cannot supply another user's factor. */ + listMfaFactors: (userId: string) => + useMfa("userManagement.listAuthFactors", (wos) => + wos.userManagement + .listAuthFactors({ userId, limit: 100 }) + .then((page) => page.autoPagination()), + ), + /** Begin AuthKit's user-bound TOTP enrollment. The secret is returned only to that user. */ + enrollMfa: (userId: string, email: string) => + useMfa("userManagement.enrollAuthFactor", (wos) => + wos.userManagement.enrollAuthFactor({ + userId, + type: "totp", + totpIssuer: "Executor", + totpUser: email, + }), + ), + /** Challenge an already resolved factor. */ + challengeMfa: (authenticationFactorId: string) => + useMfa("mfa.challengeFactor", (wos) => wos.mfa.challengeFactor({ authenticationFactorId })), + /** Verify a TOTP code with WorkOS; never log the code or factor secret. */ + verifyMfa: (authenticationChallengeId: string, code: string) => + useMfa("mfa.verifyChallenge", (wos) => + wos.mfa.verifyChallenge({ authenticationChallengeId, code }), + ), getAuthorizationUrl: (redirectUri: string, state?: string) => workos.userManagement.getAuthorizationUrl({ provider: "authkit", diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 715991f394..017570cf1a 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -5,6 +5,12 @@ declare global { namespace Cloudflare { interface Env { + /** TOTP enrollment and verification attempts; absence refuses verification. */ + ADMIN_MFA_RATE_LIMITER?: { + readonly limit: (options: { + readonly key: string; + }) => Promise<{ readonly success: boolean }>; + }; // Observability // Worker version metadata binding (wrangler.jsonc `version_metadata`). // Optional so test workers and local setups without the binding still diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index f1c4389fe7..9892af061d 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -38,6 +38,7 @@ import { NonProtectedApi, } from "../auth/handlers"; import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; +import { AdminMfaRoutes } from "../auth/admin-mfa-routes"; import { SessionAuthLive } from "../auth/middleware-live"; import { runWorkOsEventsSync } from "../auth/workos-events-runner"; import { makeWorkOsWebhookRoute } from "../auth/workos-webhook"; @@ -131,6 +132,7 @@ export const makeCloudExtensionRoutes = ( }); return [ + AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(rsLive).layer)), SessionRoutes, OrgRoutes, AdminUsersRoutes, diff --git a/apps/cloud/src/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/web/components/admin-verification.tsx b/apps/cloud/src/web/components/admin-verification.tsx new file mode 100644 index 0000000000..b0b7c8e931 --- /dev/null +++ b/apps/cloud/src/web/components/admin-verification.tsx @@ -0,0 +1,280 @@ +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 only before mounting organization API key controls. */ +export function AdminVerification({ children }: { readonly children: ReactNode }) { + const auth = useAuth(); + const scope = auth.status === "authenticated" ? auth.organization?.id : undefined; + if (!scope) return null; + return {children}; +} + +function VerificationFlow({ children }: { readonly children: ReactNode }) { + const [status, setStatus] = useState(null); + const [challenge, setChallenge] = useState(null); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const controller = useRef(null); + const codeId = useId(); + + const run = async ( + effect: Effect.Effect, + signal: AbortSignal, + onSuccess: (value: A) => void, + ) => { + const exit = await Effect.runPromiseExit(effect, { signal }); + if (signal.aborted) return; + if (Exit.isSuccess(exit)) onSuccess(exit.value); + else setError(Option.getOrNull(Cause.findErrorOption(exit.cause))?.message ?? unavailable); + }; + + useEffect(() => { + const owner = new AbortController(); + controller.current = owner; + const load = () => run(request("", decodeStatus), owner.signal, setStatus); + void load(); + window.addEventListener("focus", load); + return () => { + owner.abort(); + window.removeEventListener("focus", load); + }; + }, []); + + useEffect(() => { + if (status?.state !== "verified") return; + const timeout = window.setTimeout( + () => { + setStatus({ state: "required" }); + setChallenge(null); + setCode(""); + }, + Math.max(0, status.expiresAt * 1000 - Date.now()), + ); + return () => window.clearTimeout(timeout); + }, [status]); + + const act = async (action: "start" | "verify" | "cancel" | "retry" | "lock") => { + const signal = controller.current?.signal; + if (!signal || signal.aborted || busy) return; + setBusy(true); + setError(null); + if (action === "lock") { + await run(request("/lock", decodeCanceled, {}), signal, () => window.location.reload()); + } else if (action === "start") { + await run(request("/start", decodeChallenge, {}), signal, (next) => { + setChallenge(next); + setCode(""); + }); + } else if (action === "verify") { + await run(request("/verify", decodeVerified, { code }), signal, () => { + setChallenge(null); + setCode(""); + // Reload clears cached admin requests and the enrollment secret while + // retaining the current organization's URL. + window.location.reload(); + }); + } else if (action === "cancel") { + await run(request("/cancel", decodeCanceled, {}), signal, () => { + setChallenge(null); + setCode(""); + }); + } else { + await run(request("", decodeStatus), signal, setStatus); + } + if (!signal.aborted) setBusy(false); + }; + + if (status?.state === "member") return children; + if (status?.state === "verified") + return ( + <> +
+ Organization key management is unlocked for this session.{" "} + +
+ {children} + + ); + + return ( +
+

Unlock organization keys

+

+ Use an authenticator app to manage organization API keys for 15 minutes. +

+ 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/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