Skip to content
Merged
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/v2-session-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/cloud": patch
---

Executor Cloud hands a signed-in member of a workspace that has moved to Executor v2 to v2 with a short-lived signed token, so v2 opens the workspace without asking them to sign in again. It is off until `V2_SESSION_HANDOFF_SECRET` is set.
8 changes: 7 additions & 1 deletion apps/cloud/src/auth/doc-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,9 @@ const authorizeLastOrgSlug = async (
* Where to send this page load when the organization it opens has moved to
* v2, or `null` to serve the v1 dashboard (see `movedDashboardLocation`). The
* organization is the one the URL names (`/<slug>/…`), or for a bare path the
* session's own, the one the client canonicalizes onto. A failed read serves
* session's own, the one the client canonicalizes onto. With
* `V2_SESSION_HANDOFF_SECRET` set, the location is v2's session handoff, which
* signs the member in to v2 as the user its import mapped. A failed read serves
* the dashboard: this is navigation only, and every write the dashboard could
* make is refused server-side while the organization is moving or moved.
*/
Expand All @@ -221,6 +223,10 @@ const movedToV2Location = async (
movedDashboardLocation({
target,
v2Origin,
// Only once the session has verified: the token vouches for its user.
handoff: env.V2_SESSION_HANDOFF_SECRET
? { secret: env.V2_SESSION_HANDOFF_SECRET, user: session.userId }
: undefined,
isMember: (organizationId) =>
authorizeOrganization(session.userId, organizationId).pipe(
Effect.map((org) => org !== null),
Expand Down
7 changes: 7 additions & 0 deletions apps/cloud/src/env-augment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ declare global {
* without the binding, a moved organization's MCP requests answer a
* retryable 503 and are never served from v1. */
V2_MCP_FORWARD_SECRET?: string;
/** Shared secret that signs the session handoff token the dashboard
* gives a signed-in member of a moved organization, so v2 opens it
* without a second sign-in (`org/v2-session-handoff.ts`). Set with
* `wrangler secret put V2_SESSION_HANDOFF_SECRET`, the same value as
* v2's V1_SESSION_HANDOFF_SECRET; while unset, the dashboard redirects
* to the organization's v2 page and v2 asks the member to sign in. */
V2_SESSION_HANDOFF_SECRET?: string;
/** Absolute URL `/sign-up` and `/signup` redirect to. */
V2_SIGN_UP_URL?: string;
/** Prefix of v2's connected-account OAuth `state`; `/api/oauth/callback`
Expand Down
21 changes: 19 additions & 2 deletions apps/cloud/src/org/move-to-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { OrgWriteDeniedError } from "@executor-js/sdk";
import { isV2OrgSlug } from "../auth/v2-moved";
import { DbService, makeDbLayer } from "../db/db";
import { organizations, orgMovedToV2 } from "../db/schema";
import { mintSessionHandoffToken, sessionHandoffUrl } from "./v2-session-handoff";

/** Shown wherever a write is refused while the organization is moving. */
export const ORG_MOVING_MESSAGE =
Expand Down Expand Up @@ -160,14 +161,20 @@ export const movedDashboardUrl = (
* has `moved`, and only for a member of it, so the redirect says nothing about
* an organization the caller cannot open. The move row is read first: the
* membership read only runs for a moved organization.
*
* With `handoff` (the signed-in WorkOS user and the shared secret), the member
* goes to v2's session handoff with a token vouching for that user, so v2 opens
* the organization without a second sign-in (`v2-session-handoff.ts`). Without
* it, or without a v2 slug to open, the redirect is the plain one.
*/
export const movedDashboardLocation = <E, R>(input: {
readonly target: { readonly slug: string } | { readonly organizationId: string };
readonly v2Origin: URL;
readonly isMember: (organizationId: string) => Effect.Effect<boolean, E, R>;
readonly handoff?: { readonly secret: string; readonly user: string } | undefined;
}): Effect.Effect<string | null, OrgMoveStateReadError | E, DbService | R> =>
Effect.gen(function* () {
const { target } = input;
const { target, handoff } = input;
const found =
"slug" in target
? yield* readOrgMoveStateBySlug(target.slug)
Expand All @@ -178,7 +185,17 @@ export const movedDashboardLocation = <E, R>(input: {
);
if (found === null || found.moveState.state !== "moved") return null;
if (!(yield* input.isMember(found.organizationId))) return null;
return movedDashboardUrl(found.moveState, input.v2Origin);
const slug = found.moveState.v2OrganizationSlug;
if (handoff === undefined || !isV2OrgSlug(slug))
return movedDashboardUrl(found.moveState, input.v2Origin);
const token = yield* Effect.promise(() =>
mintSessionHandoffToken(handoff.secret, {
user: handoff.user,
organization: found.organizationId,
slug,
}),
);
return sessionHandoffUrl(input.v2Origin, token);
});

/** Succeeds only when the organization accepts writes in v1. */
Expand Down
86 changes: 86 additions & 0 deletions apps/cloud/src/org/v2-session-handoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// ---------------------------------------------------------------------------
// Handing a member's verified v1 session to Executor v2.
//
// v2 never signs anyone in with WorkOS. When the dashboard redirects a
// signed-in member of an organization that has moved, it sends them to v2's
// `/api/v1-handoff` with a token vouching for the WorkOS session it has just
// verified, and v2 starts its own session for the user its import mapped:
//
// v1hnd.<payload>.<sig>
//
// payload base64url (no padding) of the UTF-8 JSON
// {"v":1,"aud":"v2-session-handoff","user","organization","slug",
// "iat","exp","jti"}
// sig base64url (no padding) of HMAC-SHA256 over "v1hnd." + payload,
// keyed by the UTF-8 bytes of V2_SESSION_HANDOFF_SECRET
//
// `user` is the WorkOS user, `organization` the v1 organization, `slug` the
// v2 organization to open. A token lives a minute and v2 accepts each `jti`
// once. The secret is its own: it is never the MCP forward secret.
// ---------------------------------------------------------------------------

const TOKEN_PREFIX = "v1hnd";
const TOKEN_VERSION = 1;
export const SESSION_HANDOFF_AUDIENCE = "v2-session-handoff";
export const SESSION_HANDOFF_TTL_SECONDS = 60;

/** v2's route that turns a token into a v2 session. */
export const SESSION_HANDOFF_PATH = "/api/v1-handoff";

const encoder = new TextEncoder();

const base64Url = (bytes: Uint8Array): string => {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
};

export interface SessionHandoffClaims {
/** The WorkOS user whose session v1 verified. */
readonly user: string;
/** The v1 organization that moved. */
readonly organization: string;
/** The v2 organization to open. */
readonly slug: string;
}

export const mintSessionHandoffToken = async (
secret: string,
claims: SessionHandoffClaims,
nowSeconds: number = Math.floor(Date.now() / 1000),
): Promise<string> => {
const payload = base64Url(
encoder.encode(
JSON.stringify({
v: TOKEN_VERSION,
aud: SESSION_HANDOFF_AUDIENCE,
user: claims.user,
organization: claims.organization,
slug: claims.slug,
iat: nowSeconds,
exp: nowSeconds + SESSION_HANDOFF_TTL_SECONDS,
jti: base64Url(crypto.getRandomValues(new Uint8Array(18))),
}),
),
);
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(`${TOKEN_PREFIX}.${payload}`),
);
return `${TOKEN_PREFIX}.${payload}.${base64Url(new Uint8Array(signature))}`;
};

/** Where v1 sends the member: v2's handoff route on its app host. */
export const sessionHandoffUrl = (v2Origin: URL, token: string): string => {
const url = new URL(SESSION_HANDOFF_PATH, v2Origin);
url.searchParams.set("token", token);
return url.href;
};
Loading