Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/admin-key-verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/api": patch
---

Allow account providers to require extra verification before issuing a user API key.
3 changes: 2 additions & 1 deletion apps/cloud/src/account/account-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions apps/cloud/src/account/org-api-key-revoke.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const session = (accountId: string) => ({
name: null,
avatarUrl: null,
organizationId: ORG,
adminVerified: true,
sealedSession: "sealed",
refreshedSession: null,
});
Expand Down
13 changes: 12 additions & 1 deletion apps/cloud/src/account/workos-account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -259,6 +267,9 @@ export const workosAccountProvider: Layer.Layer<
createApiKey: (headers, name) =>
Effect.gen(function* () {
const { session, org } = yield* requireOrganization(headers);
// An admin's personal key inherits workspace write permission.
// Do not let an unverified browser session mint that credential.
if (org.memberRole === "admin") yield* requireAdmin(org);
const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH);
if (!trimmed) {
return yield* new AccountError({
Expand Down
21 changes: 18 additions & 3 deletions apps/cloud/src/admin/admin-users-api.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -144,6 +144,7 @@ const stubWorkOS = (userId: string) =>
return () =>
Effect.succeed({
userId,
adminVerified,
email: `${userId}@placeholder.test`,
organizationId: null,
});
Expand All @@ -153,14 +154,20 @@ const stubWorkOS = (userId: string) =>
}),
);

const authorizeAs = (userId: string) =>
const authorizeAs = (userId: string, adminVerified = true) =>
authorizeTenant(
new Request("https://admin.invalid", {
headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG },
}),
).pipe(
Effect.provide(
Layer.mergeAll(stubDirectory, stubApiKeys, stubUsers, stubWorkOS(userId), stubMirror),
Layer.mergeAll(
stubDirectory,
stubApiKeys,
stubUsers,
stubWorkOS(userId, adminVerified),
stubMirror,
),
),
);

Expand All @@ -172,6 +179,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"));
Expand Down
1 change: 1 addition & 0 deletions apps/cloud/src/admin/admin-users-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,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;
});

Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/src/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions apps/cloud/src/auth/access-token-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ import type { JWTVerifyOptions } from "jose";
* that live for several days.
*/
export const workosAccessTokenOptions: JWTVerifyOptions = {
algorithms: ["RS256"],
requiredClaims: ["exp", "iat"],
};
122 changes: 122 additions & 0 deletions apps/cloud/src/auth/admin-mfa-proof.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}),
);
});
89 changes: 89 additions & 0 deletions apps/cloud/src/auth/admin-mfa-proof.ts
Original file line number Diff line number Diff line change
@@ -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)),
);
};
Loading
Loading