From e982d35cd9e085c954e0f35a947c7649c9c8d6fd Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:40:48 -0700 Subject: [PATCH] Hand a moved workspace's signed-in member to v2 with a signed token --- .changeset/v2-session-handoff.md | 5 ++ apps/cloud/src/auth/doc-gate.ts | 8 ++- apps/cloud/src/env-augment.d.ts | 7 ++ apps/cloud/src/org/move-to-v2.ts | 21 +++++- apps/cloud/src/org/v2-session-handoff.ts | 86 ++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 .changeset/v2-session-handoff.md create mode 100644 apps/cloud/src/org/v2-session-handoff.ts diff --git a/.changeset/v2-session-handoff.md b/.changeset/v2-session-handoff.md new file mode 100644 index 0000000000..332eb4a113 --- /dev/null +++ b/.changeset/v2-session-handoff.md @@ -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. diff --git a/apps/cloud/src/auth/doc-gate.ts b/apps/cloud/src/auth/doc-gate.ts index 3b4455ee73..28b4f4af2e 100644 --- a/apps/cloud/src/auth/doc-gate.ts +++ b/apps/cloud/src/auth/doc-gate.ts @@ -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 (`//…`), 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. */ @@ -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), diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 6603749350..c972d53541 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -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` diff --git a/apps/cloud/src/org/move-to-v2.ts b/apps/cloud/src/org/move-to-v2.ts index 879d16fc34..1ba89947ce 100644 --- a/apps/cloud/src/org/move-to-v2.ts +++ b/apps/cloud/src/org/move-to-v2.ts @@ -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 = @@ -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 = (input: { readonly target: { readonly slug: string } | { readonly organizationId: string }; readonly v2Origin: URL; readonly isMember: (organizationId: string) => Effect.Effect; + readonly handoff?: { readonly secret: string; readonly user: string } | undefined; }): Effect.Effect => Effect.gen(function* () { - const { target } = input; + const { target, handoff } = input; const found = "slug" in target ? yield* readOrgMoveStateBySlug(target.slug) @@ -178,7 +185,17 @@ export const movedDashboardLocation = (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. */ diff --git a/apps/cloud/src/org/v2-session-handoff.ts b/apps/cloud/src/org/v2-session-handoff.ts new file mode 100644 index 0000000000..63be6062ee --- /dev/null +++ b/apps/cloud/src/org/v2-session-handoff.ts @@ -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 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 => { + 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; +};