History

CLI device-flow login for hosted accounts (`executor login`) (#1026)

* Add `executor login` device-flow auth for hosted accounts Let users sign the CLI into a hosted Executor account with the OAuth 2.0 Device Authorization Grant (RFC 8628) instead of hand-pasting an API key. - CLI: `executor login` / `logout` / `whoami`. `login` discovers the provider device endpoints from the server (`GET /api/auth/cli-login`), runs RFC 8628 (request code, open the browser, poll), and stores the resulting tokens in the server profile. The credential is refreshed before expiry on subsequent calls. - SDK + CLI profile: new `oauth` auth kind (access + refresh token + expiry) alongside basic/bearer; sent as `Authorization: Bearer`. - Cloud: the `/api/*` plane now accepts a provider access-token JWT as a bearer (in addition to API keys and the session cookie), reusing the existing MCP JWT verifier; plus a public `/api/auth/cli-login` discovery endpoint. - Fix: a stored credential (oauth/bearer) no longer triggers local daemon auto-start for an http://localhost server. - e2e: a cloud scenario drives the real CLI through the device flow and asserts an authenticated `/api/*` call, recorded for the viewer. The flow is provider-neutral; self-host (Better Auth device authorization) is a follow-up. Driving the cloud e2e depends on a device-flow capable build of the service emulator (developed in the fork; to be published). * CLI login: name profiles by account, not hostname Two accounts on the same host previously collided (the profile name was derived from the hostname, so a second login overwrote the first). Name the profile by the authenticated account instead: capture the email + org from the device-token response and pick the name from that, finding-or- creating so a re-login updates the same account's profile while a different account gets its own. Mirrors how opencode keys accounts (by email), and fixes the same-host clobber. Adds an e2e proving two same-host accounts yield two retained profiles. * e2e: record the browser approval; don't gate authed connections on liveness probe - The device-login e2e now drives the real verification page in Playwright (recorded to session.mp4) alongside the terminal cast, instead of approving headlessly — the recording shows the actual browser hop. - Resolving an authenticated remote connection (oauth/bearer/basic) no longer requires the unauthenticated /api/health liveness probe to pass; that probe only gates the local auto-start decision and isn't exposed by hosted servers. The real API call surfaces any connectivity/auth error. - Update the JWT auth test's org-store stub for the new org slug field. * self-host: executor login device flow (Better Auth) Brings `executor login` to the self-host app, mirroring cloud: - Add Better Auth's deviceAuthorization() plugin; the issued session token is accepted as a Bearer on /api/* via the existing bearer() plugin (no identity-layer change). - Serve GET /api/auth/cli-login discovery (provider better-auth, the /api/auth/device/{code,token} endpoints, clientId executor-cli) and a /device verification page that binds + approves the pending code with the session cookie. Dev vite forwards /device to the Effect handler. - CLI: request encoding is discovery-driven (requestFormat form|json) since Better Auth's device endpoints only accept JSON while WorkOS needs form. - e2e: a self-host scenario drives the real binary + browser approval and an authenticated /api call, recorded (terminal.cast + session.mp4). * e2e: anchor the terminal recording on the focus timeline The cli.session surface now writes a terminal anchor + focus to timeline.json (symmetric with the browser surface), so a combined cli+browser scenario produces the synced SessionPlayer view (terminal/browser cuts + the synthesized URL bar) instead of falling back to separate clips. The device-login scenarios cut focus back to the terminal after the browser approval so the final 'Logged in' + authenticated /api call land on the terminal. * address review: remove em-dashes, scope E2E_FILM, pin verificationUri - Replace em-dash characters in the comments this PR added (repo style rule). - Restore E2E_FILM after the device-login scenarios via a scope finalizer so the filmed-mode flag doesn't leak into later serial e2e files. - Pin Better Auth deviceAuthorization verificationUri to /device (its default, made explicit). * self-host device page: a console SPA route, not raw HTML Replace the server-rendered /device HTML with a real console route (web/device.tsx) rendered chromeless inside the auth gate, the same static-import + pathname-branch convention as the MCP consent screen. It uses the shared Button + design-system styles and drives Better Auth's device endpoints (claim, then approve/deny) with plain fetch. Drops the bespoke HTML route from app.ts and the /device dev-forward from vite.config.ts (the SPA now serves /device in dev and prod).

Rhys Sullivan committed 4 months ago

Commit 7c873ec1 parent 6c2478417 files · +1557 −13Browse files

Unified · Split

17 files
  1. apps/cli/src/device-login.ts +328 −0
  2. apps/cli/src/main.ts +321 −1
  3. apps/cli/src/server-connection.ts +4 −2
  4. apps/cli/src/server-profile.ts +8 −0
  5. apps/cloud/src/api/protected-jwt-auth.node.test.ts +169 −0
  6. apps/cloud/src/auth/api-jwt-bearer.ts +35 −0
  7. apps/cloud/src/auth/api.ts +13 −1
  8. apps/cloud/src/auth/handlers.ts +16 −0
  9. apps/cloud/src/auth/workos-auth-provider.ts +90 −4
  10. apps/host-selfhost/src/app.ts +23 −1
  11. apps/host-selfhost/src/auth/better-auth.ts +9 −1
  12. apps/host-selfhost/web/device.tsx +137 −0
  13. apps/host-selfhost/web/routes/__root.tsx +15 −0
  14. e2e/cloud/cli-device-login.test.ts +223 −0
  15. e2e/selfhost/cli-device-login.test.ts +135 −0
  16. e2e/src/surfaces/cli.ts +10 −3
  17. packages/core/sdk/src/server-connection.ts +21 −0
apps/cli/src/device-login.ts+328−0
@@ -0,0 +1,328 @@
1// ---------------------------------------------------------------------------
2// CLI device-login (OAuth 2.0 Device Authorization Grant, RFC 8628).
3//
4// `executor login` against a hosted server:
5// 1. discover the provider device endpoints + public client id from the
6// server (`GET /api/auth/cli-login`),
7// 2. request a device code, show the user_code + open the verification URL,
8// 3. poll the token endpoint until the user approves in the browser,
9// 4. hand the access + refresh tokens back to be stored in the profile.
10//
11// The flow is provider-neutral: the server advertises WorkOS (cloud) or Better
12// Auth (self-host) endpoints, and both speak the same RFC 8628 wire shape, so
13// this module never branches on provider. Tokens are sent to the `/api/*`
14// plane as `Authorization: Bearer <access_token>`.
15// ---------------------------------------------------------------------------
16
17import { spawn } from "node:child_process";
18
19export interface CliLoginDiscovery {
20 readonly provider: string;
21 readonly deviceAuthorizationEndpoint: string;
22 readonly tokenEndpoint: string;
23 readonly clientId: string;
24 readonly scope?: string;
25 /**
26 * How to encode the device-authorization + token requests. RFC 8628 mandates
27 * `form` (WorkOS), but some providers' endpoints only accept JSON (Better
28 * Auth). The server tells us via discovery; defaults to `form`.
29 */
30 readonly requestFormat: "form" | "json";
31}
32
33export interface DeviceCodeGrant {
34 readonly deviceCode: string;
35 readonly userCode: string;
36 readonly verificationUri: string;
37 readonly verificationUriComplete?: string;
38 readonly expiresInSeconds: number;
39 readonly intervalSeconds: number;
40}
41
42export interface DeviceTokens {
43 readonly accessToken: string;
44 readonly refreshToken?: string;
45 /** Epoch seconds the access token expires, when known. */
46 readonly expiresAt?: number;
47 /** The signed-in account's email, if the token response carries a user. */
48 readonly email?: string;
49 /** The bound organization id, from the response or the token's org_id claim. */
50 readonly organizationId?: string;
51}
52
53const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
54const DEFAULT_INTERVAL_SECONDS = 5;
55
56export class DeviceLoginError extends Error {
57 constructor(message: string) {
58 super(message);
59 this.name = "DeviceLoginError";
60 }
61}
62
63const cliLoginUrl = (origin: string): string => `${origin.replace(/\/+$/, "")}/api/auth/cli-login`;
64
65const asString = (value: unknown): string | undefined =>
66 typeof value === "string" && value.length > 0 ? value : undefined;
67
68const asNumber = (value: unknown): number | undefined =>
69 typeof value === "number" && Number.isFinite(value) ? value : undefined;
70
71/** Pull `user.email` out of a token response (WorkOS returns the user inline). */
72const readUserEmail = (body: Record<string, unknown>): string | undefined => {
73 const user = body.user;
74 if (user && typeof user === "object" && "email" in user) {
75 return asString((user as { email?: unknown }).email);
76 }
77 return undefined;
78};
79
80/**
81 * Decode a JWT access token's claims WITHOUT verifying it. The CLI only uses
82 * this for display (`whoami`, the post-login summary) and to read `exp`, the
83 * server is the only thing that verifies the token's signature.
84 */
85export const decodeAccessTokenClaims = (
86 accessToken: string,
87): Record<string, unknown> | undefined => {
88 const segments = accessToken.split(".");
89 if (segments.length !== 3) return undefined;
90 const payloadSegment = segments[1];
91 if (!payloadSegment) return undefined;
92 try {
93 const json = Buffer.from(payloadSegment, "base64url").toString("utf8");
94 const claims = JSON.parse(json) as unknown;
95 return claims && typeof claims === "object" ? (claims as Record<string, unknown>) : undefined;
96 } catch {
97 return undefined;
98 }
99};
100
101/** Read `exp` (epoch seconds) out of a JWT access token without verifying it. */
102export const accessTokenExpiry = (accessToken: string): number | undefined =>
103 asNumber(decodeAccessTokenClaims(accessToken)?.exp);
104
105const deriveExpiresAt = (tokens: {
106 accessToken: string;
107 expiresIn?: number;
108}): number | undefined => {
109 if (tokens.expiresIn !== undefined) {
110 return Math.floor(Date.now() / 1000) + tokens.expiresIn;
111 }
112 return accessTokenExpiry(tokens.accessToken);
113};
114
115const formBody = (fields: Record<string, string | undefined>): string => {
116 const params = new URLSearchParams();
117 for (const [key, value] of Object.entries(fields)) {
118 if (value !== undefined) params.set(key, value);
119 }
120 return params.toString();
121};
122
123const definedFields = (fields: Record<string, string | undefined>): Record<string, string> =>
124 Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)) as Record<
125 string,
126 string
127 >;
128
129const post = async (
130 url: string,
131 fields: Record<string, string | undefined>,
132 format: "form" | "json",
133) =>
134 fetch(url, {
135 method: "POST",
136 headers: {
137 "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded",
138 accept: "application/json",
139 },
140 body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),
141 });
142
143const readJson = async (response: Response): Promise<Record<string, unknown>> => {
144 const text = await response.text();
145 if (!text) return {};
146 try {
147 return JSON.parse(text) as Record<string, unknown>;
148 } catch {
149 return { error: "invalid_response", error_description: text.slice(0, 200) };
150 }
151};
152
153export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => {
154 let response: Response;
155 try {
156 response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } });
157 } catch (cause) {
158 throw new DeviceLoginError(
159 `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`,
160 );
161 }
162 if (!response.ok) {
163 throw new DeviceLoginError(
164 `${origin} does not support CLI login (GET /api/auth/cli-login returned ${response.status}). ` +
165 "It may be an older server, or a local/unauthenticated server that needs no login.",
166 );
167 }
168 const body = await readJson(response);
169 const deviceAuthorizationEndpoint = asString(body.deviceAuthorizationEndpoint);
170 const tokenEndpoint = asString(body.tokenEndpoint);
171 const clientId = asString(body.clientId);
172 if (!deviceAuthorizationEndpoint || !tokenEndpoint || !clientId) {
173 throw new DeviceLoginError(`${origin} returned an incomplete CLI-login configuration.`);
174 }
175 return {
176 provider: asString(body.provider) ?? "unknown",
177 deviceAuthorizationEndpoint,
178 tokenEndpoint,
179 clientId,
180 scope: asString(body.scope),
181 requestFormat: asString(body.requestFormat) === "json" ? "json" : "form",
182 };
183};
184
185export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => {
186 const response = await post(
187 discovery.deviceAuthorizationEndpoint,
188 { client_id: discovery.clientId, scope: discovery.scope },
189 discovery.requestFormat,
190 );
191 const body = await readJson(response);
192 if (!response.ok) {
193 throw new DeviceLoginError(
194 `Device authorization failed (${response.status}): ${asString(body.error_description) ?? asString(body.error) ?? "unknown error"}`,
195 );
196 }
197 const deviceCode = asString(body.device_code);
198 const userCode = asString(body.user_code);
199 const verificationUri = asString(body.verification_uri);
200 if (!deviceCode || !userCode || !verificationUri) {
201 throw new DeviceLoginError("Device authorization response was missing required fields.");
202 }
203 return {
204 deviceCode,
205 userCode,
206 verificationUri,
207 verificationUriComplete: asString(body.verification_uri_complete),
208 expiresInSeconds: asNumber(body.expires_in) ?? 300,
209 intervalSeconds: asNumber(body.interval) ?? DEFAULT_INTERVAL_SECONDS,
210 };
211};
212
213const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
214
215/**
216 * Poll the token endpoint until the user approves, honoring the RFC 8628
217 * pacing rules (`authorization_pending` keeps waiting, `slow_down` backs off,
218 * `access_denied` / `expired_token` are terminal).
219 */
220export const pollForDeviceTokens = async (
221 discovery: CliLoginDiscovery,
222 grant: DeviceCodeGrant,
223 options: { readonly now?: () => number } = {},
224): Promise<DeviceTokens> => {
225 const now = options.now ?? (() => Date.now());
226 const deadline = now() + grant.expiresInSeconds * 1000;
227 let intervalMs = Math.max(1, grant.intervalSeconds) * 1000;
228
229 for (;;) {
230 if (now() >= deadline) {
231 throw new DeviceLoginError("Login timed out before it was approved.");
232 }
233 await sleep(intervalMs);
234
235 const response = await post(
236 discovery.tokenEndpoint,
237 {
238 grant_type: DEVICE_CODE_GRANT_TYPE,
239 device_code: grant.deviceCode,
240 client_id: discovery.clientId,
241 },
242 discovery.requestFormat,
243 );
244 const body = await readJson(response);
245
246 if (response.ok) {
247 const accessToken = asString(body.access_token);
248 if (!accessToken) {
249 throw new DeviceLoginError("Token response was missing an access token.");
250 }
251 const claims = decodeAccessTokenClaims(accessToken);
252 return {
253 accessToken,
254 refreshToken: asString(body.refresh_token),
255 expiresAt: deriveExpiresAt({ accessToken, expiresIn: asNumber(body.expires_in) }),
256 email:
257 readUserEmail(body) ?? (typeof claims?.email === "string" ? claims.email : undefined),
258 organizationId:
259 asString(body.organization_id) ??
260 (typeof claims?.org_id === "string" ? claims.org_id : undefined),
261 };
262 }
263
264 const error = asString(body.error);
265 if (error === "authorization_pending") continue;
266 if (error === "slow_down") {
267 intervalMs += 5000;
268 continue;
269 }
270 if (error === "access_denied") {
271 throw new DeviceLoginError("Login was denied.");
272 }
273 if (error === "expired_token") {
274 throw new DeviceLoginError("The login request expired before it was approved.");
275 }
276 throw new DeviceLoginError(
277 `Login failed: ${asString(body.error_description) ?? error ?? `HTTP ${response.status}`}`,
278 );
279 }
280};
281
282/** Exchange a refresh token for a fresh access token (silent re-auth). Only
283 * providers that issue refresh tokens reach here (WorkOS, which is form-encoded
284 * per RFC 8628); Better Auth's device flow issues no refresh token. */
285export const refreshDeviceTokens = async (input: {
286 readonly tokenEndpoint: string;
287 readonly clientId: string;
288 readonly refreshToken: string;
289}): Promise<DeviceTokens> => {
290 const response = await post(
291 input.tokenEndpoint,
292 {
293 grant_type: "refresh_token",
294 refresh_token: input.refreshToken,
295 client_id: input.clientId,
296 },
297 "form",
298 );
299 const body = await readJson(response);
300 if (!response.ok) {
301 throw new DeviceLoginError(
302 `Token refresh failed: ${asString(body.error_description) ?? asString(body.error) ?? `HTTP ${response.status}`}`,
303 );
304 }
305 const accessToken = asString(body.access_token);
306 if (!accessToken) throw new DeviceLoginError("Refresh response was missing an access token.");
307 return {
308 accessToken,
309 refreshToken: asString(body.refresh_token) ?? input.refreshToken,
310 expiresAt: deriveExpiresAt({ accessToken, expiresIn: asNumber(body.expires_in) }),
311 };
312};
313
314/** Best-effort open the verification URL in the user's default browser. */
315export const openBrowser = (url: string): void => {
316 const platform = process.platform;
317 const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
318 const args = platform === "win32" ? ["/c", "start", "", url] : [url];
319 try {
320 const child = spawn(command, args, { stdio: "ignore", detached: true });
321 // Swallow async spawn failures (e.g. the opener binary is missing), the
322 // URL is printed too, so the user can open it by hand.
323 (child as { on?: (event: "error", listener: () => void) => void }).on?.("error", () => {});
324 child.unref();
325 } catch {
326 // Ignore, the URL is also printed for manual opening.
327 }
328};
apps/cli/src/main.ts+321−1
@@ -69,11 +69,20 @@ import { ExecutorApi } from "@executor-js/api"; · 68 unmodified lines
69import {69import {
70 getExecutorServerAuthorizationHeader,70 getExecutorServerAuthorizationHeader,
71 normalizeExecutorServerConnection,71 normalizeExecutorServerConnection,
72 normalizeExecutorServerOrigin,
72 type ExecutorLocalServerKind,73 type ExecutorLocalServerKind,
73 type ExecutorLocalServerManifest,74 type ExecutorLocalServerManifest,
74 type ExecutorServerConnection,75 type ExecutorServerConnection,
75 type ExecutorServerConnectionInput,76 type ExecutorServerConnectionInput,
76} from "@executor-js/sdk/shared";77} from "@executor-js/sdk/shared";
78import {
79 decodeAccessTokenClaims,
80 discoverCliLogin,
81 openBrowser,
82 pollForDeviceTokens,
83 refreshDeviceTokens,
84 requestDeviceCode,
85} from "./device-login";
77import {86import {
78 startServer,87 startServer,
79 runMcpStdioServer,88 runMcpStdioServer,
@@ -138,6 +147,7 @@ import { · 58 unmodified lines
138 setDefaultCliServerConnectionProfile,147 setDefaultCliServerConnectionProfile,
139 upsertCliServerConnectionProfile,148 upsertCliServerConnectionProfile,
140 validateCliServerConnectionProfileName,149 validateCliServerConnectionProfileName,
150 type CliServerConnectionStore,
141} from "./server-profile";151} from "./server-profile";
142import {152import {
143 buildResumeContentTemplate,153 buildResumeContentTemplate,
@@ -587,6 +597,59 @@ const resolveRequestedExecutorServerConnection = ( · 443 unmodified lines
587 };597 };
588 });598 });
589 599
600// Refresh an `oauth` (device-login) credential a minute before it expires, so
601// `executor call` against a hosted server keeps working long after the browser
602// login. The refreshed tokens are written back to the originating profile.
603const OAUTH_REFRESH_SKEW_SECONDS = 60;
604
605const profileNameFromKey = (key: string): string | null =>
606 key.startsWith("profile:") ? key.slice("profile:".length) : null;
607
608const refreshOAuthConnection = (
609 connection: ExecutorServerConnection,
610): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> =>
611 Effect.gen(function* () {
612 const auth = connection.auth;
613 if (!auth || auth.kind !== "oauth") return connection;
614 const now = Math.floor(Date.now() / 1000);
615 if (auth.expiresAt && auth.expiresAt - now > OAUTH_REFRESH_SKEW_SECONDS) return connection;
616 // Destructure so the narrowed string types survive into the deferred
617 // `tryPromise` callback (where TS would otherwise re-widen the fields).
618 const { refreshToken, tokenEndpoint, clientId } = auth;
619 if (!refreshToken || !tokenEndpoint || !clientId) return connection;
620
621 const refreshed = yield* Effect.tryPromise({
622 try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }),
623 catch: toError,
624 // On a failed refresh, keep the existing token and let the eventual 401
625 // surface, better than blocking the command on a transient hiccup.
626 }).pipe(Effect.option);
627 if (Option.isNone(refreshed)) return connection;
628
629 const next = refreshed.value;
630 const nextConnection = normalizeExecutorServerConnection({
631 ...connection,
632 auth: {
633 kind: "oauth",
634 accessToken: next.accessToken,
635 refreshToken: next.refreshToken ?? refreshToken,
636 ...(next.expiresAt ? { expiresAt: next.expiresAt } : {}),
637 tokenEndpoint,
638 clientId,
639 },
640 });
641
642 const profileName = profileNameFromKey(connection.key);
643 if (profileName) {
644 yield* upsertCliServerConnectionProfile({
645 name: profileName,
646 connection: nextConnection,
647 makeDefault: false,
648 }).pipe(Effect.ignore);
649 }
650 return nextConnection;
651 });
652
590const resolveExecutorServerConnection = (653const resolveExecutorServerConnection = (
591 target: ServerTarget,654 target: ServerTarget,
592): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> =>655): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> =>
@@ -612,10 +675,16 @@ const resolveExecutorServerConnection = ( · 19 unmodified lines
612 );675 );
613 }676 }
614 677
615 const requested = decision.connection;678 const requested = yield* refreshOAuthConnection(decision.connection);
616 if (decision.kind === "use-active") return requested;679 if (decision.kind === "use-active") return requested;
617 680
618 if (!canAutoStartCliServerConnection(requested)) {681 if (!canAutoStartCliServerConnection(requested)) {
682 // An authenticated remote connection (oauth device-login, bearer key, or
683 // basic password): use it directly. The /api/health liveness probe is only
684 // a gate for the local auto-start decision and isn't necessarily exposed
685 // by hosted servers, the real API call surfaces any connectivity/auth
686 // error with proper context.
687 if (requested.auth) return requested;
619 if (yield* isServerReachable(requested.origin)) {688 if (yield* isServerReachable(requested.origin)) {
620 return requested;689 return requested;
621 }690 }
@@ -1995,6 +2064,254 @@ const serverCommand = Command.make("server").pipe( · 1373 unmodified lines
1995 Command.withDescription("Manage named Executor server profiles"),2064 Command.withDescription("Manage named Executor server profiles"),
1996);2065);
1997 2066
2067const loginHostLabel = (origin: string): string => {
2068 try {
2069 const host = new URL(origin).hostname;
2070 const first = host.split(".")[0];
2071 return first && first.length > 0 ? first : host;
2072 } catch {
2073 return "server";
2074 }
2075};
2076
2077const sanitizeProfileName = (raw: string): string => {
2078 const cleaned = raw
2079 .trim()
2080 .replace(/[^A-Za-z0-9_.-]+/g, "-")
2081 .replace(/^-+|-+$/g, "");
2082 return cleaned.length > 0 ? cleaned : "server";
2083};
2084
2085// The (origin, user, org) a stored oauth profile authenticates as, lets us
2086// recognize a re-login to the SAME account (update in place) versus a
2087// different account on the same host (needs its own profile).
2088const oauthAccountIdentity = (connection: ExecutorServerConnection): string | null => {
2089 const auth = connection.auth;
2090 if (!auth || auth.kind !== "oauth") return null;
2091 const claims = decodeAccessTokenClaims(auth.accessToken);
2092 const sub = typeof claims?.sub === "string" ? claims.sub : undefined;
2093 const org = typeof claims?.org_id === "string" ? claims.org_id : undefined;
2094 return sub && org ? `${connection.origin}|${sub}|${org}` : null;
2095};
2096
2097// Name a login's profile by the ACCOUNT it authenticates (email, falling back
2098// to user id), not the hostname, so two accounts on the same server get
2099// distinct profiles instead of clobbering each other (the way opencode keys
2100// accounts by email/url). A re-login to the same account reuses its profile.
2101const chooseLoginProfileName = (
2102 store: CliServerConnectionStore,
2103 account: {
2104 readonly origin: string;
2105 readonly sub?: string;
2106 readonly org?: string;
2107 readonly email?: string;
2108 },
2109): string => {
2110 const identity =
2111 account.sub && account.org ? `${account.origin}|${account.sub}|${account.org}` : null;
2112 if (identity) {
2113 const existing = store.profiles.find((p) => oauthAccountIdentity(p.connection) === identity);
2114 if (existing) return existing.name;
2115 }
2116 const base = sanitizeProfileName(account.email ?? account.sub ?? loginHostLabel(account.origin));
2117 if (!store.profiles.some((p) => p.name === base)) return base;
2118 for (let suffix = 2; ; suffix += 1) {
2119 const candidate = `${base}-${suffix}`;
2120 if (!store.profiles.some((p) => p.name === candidate)) return candidate;
2121 }
2122};
2123
2124// Resolve which server a login/logout targets: an existing profile (--server
2125// or the default) or a bare origin (--base-url). The profile name is decided
2126// later, from the authenticated account.
2127const resolveLoginOrigin = (input: {
2128 readonly baseUrl: Option.Option<string>;
2129 readonly server: Option.Option<string>;
2130}): Effect.Effect<
2131 { readonly origin: string; readonly profile: ReturnType<typeof findCliServerConnectionProfile> },
2132 Error,
2133 FileSystem.FileSystem | PlatformPath.Path
2134> =>
2135 Effect.gen(function* () {
2136 const baseUrl = Option.getOrUndefined(input.baseUrl);
2137 const serverName = Option.getOrUndefined(input.server);
2138 if (baseUrl && serverName) {
2139 return yield* Effect.fail(new Error("Use either --server or --base-url, not both."));
2140 }
2141 if (serverName) {
2142 const store = yield* readCliServerConnectionStore();
2143 const profile = findCliServerConnectionProfile(store, serverName);
2144 if (!profile)
2145 return yield* Effect.fail(new Error(`No server profile named "${serverName}".`));
2146 return { origin: profile.connection.origin, profile };
2147 }
2148 if (baseUrl) {
2149 return { origin: normalizeExecutorServerOrigin(baseUrl), profile: null };
2150 }
2151 const store = yield* readCliServerConnectionStore();
2152 const profile = defaultCliServerConnectionProfile(store);
2153 if (profile) return { origin: profile.connection.origin, profile };
2154 return yield* Effect.fail(
2155 new Error(
2156 "No server to log in to. Pass --base-url <https://your-host> or --server <profile>.",
2157 ),
2158 );
2159 });
2160
2161const loginNameOption = Options.string("name").pipe(
2162 Options.optional,
2163 Options.withDescription("Profile name to save the login under (defaults to your account)."),
2164);
2165
2166const noBrowserOption = Options.boolean("no-browser").pipe(
2167 Options.withDefault(false),
2168 Options.withDescription("Print the verification URL instead of opening a browser."),
2169);
2170
2171const loginCommand = Command.make(
2172 "login",
2173 {
2174 baseUrl: serverBaseUrl,
2175 server: serverProfile,
2176 name: loginNameOption,
2177 noBrowser: noBrowserOption,
2178 },
2179 ({ baseUrl, server, name, noBrowser }) =>
2180 Effect.gen(function* () {
2181 const target = yield* resolveLoginOrigin({ baseUrl, server });
2182 const explicitName = Option.getOrUndefined(name);
2183 const discovery = yield* Effect.tryPromise({
2184 try: () => discoverCliLogin(target.origin),
2185 catch: toError,
2186 });
2187 const grant = yield* Effect.tryPromise({
2188 try: () => requestDeviceCode(discovery),
2189 catch: toError,
2190 });
2191 const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri;
2192 console.log("");
2193 console.log(`To sign in, open: ${verifyUrl}`);
2194 console.log(`and confirm the code: ${grant.userCode}`);
2195 console.log("");
2196 if (!noBrowser) openBrowser(verifyUrl);
2197 console.log("Waiting for you to approve in the browser...");
2198 const tokens = yield* Effect.tryPromise({
2199 try: () => pollForDeviceTokens(discovery, grant),
2200 catch: toError,
2201 });
2202
2203 const claims = decodeAccessTokenClaims(tokens.accessToken);
2204 const sub = typeof claims?.sub === "string" ? claims.sub : undefined;
2205 const org =
2206 tokens.organizationId ?? (typeof claims?.org_id === "string" ? claims.org_id : undefined);
2207 const email = tokens.email;
2208
2209 // Name by account so a different account on the same host doesn't clobber
2210 // an existing login; --server / the default profile / --name pin it.
2211 const store = yield* readCliServerConnectionStore();
2212 const profileName = explicitName
2213 ? validateCliServerConnectionProfileName(explicitName)
2214 : target.profile
2215 ? target.profile.name
2216 : chooseLoginProfileName(store, { origin: target.origin, sub, org, email });
2217
2218 yield* upsertCliServerConnectionProfile({
2219 name: profileName,
2220 connection: {
2221 kind: "http",
2222 origin: target.origin,
2223 ...(email ? { displayName: email } : {}),
2224 auth: {
2225 kind: "oauth",
2226 accessToken: tokens.accessToken,
2227 ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
2228 ...(tokens.expiresAt ? { expiresAt: tokens.expiresAt } : {}),
2229 tokenEndpoint: discovery.tokenEndpoint,
2230 clientId: discovery.clientId,
2231 },
2232 },
2233 makeDefault: true,
2234 });
2235
2236 console.log("");
2237 console.log(`Logged in to ${target.origin} (profile "${profileName}", now the default).`);
2238 if (email) console.log(`Account: ${email}`);
2239 if (org) console.log(`Organization: ${org}`);
2240 else if (sub) console.log(`User: ${sub}`);
2241 }),
2242).pipe(Command.withDescription("Sign in to a hosted Executor server in the browser (device flow)"));
2243
2244const logoutCommand = Command.make(
2245 "logout",
2246 { baseUrl: serverBaseUrl, server: serverProfile },
2247 ({ baseUrl, server }) =>
2248 Effect.gen(function* () {
2249 const target = yield* resolveLoginOrigin({ baseUrl, server });
2250 const store = yield* readCliServerConnectionStore();
2251 // --server / default give the profile directly; --base-url matches by origin.
2252 const profile =
2253 target.profile ?? store.profiles.find((p) => p.connection.origin === target.origin) ?? null;
2254 if (!profile) {
2255 console.log(`No stored login for ${target.origin}.`);
2256 return;
2257 }
2258 if (!profile.connection.auth) {
2259 console.log(`Profile "${profile.name}" has no stored credentials.`);
2260 return;
2261 }
2262 yield* upsertCliServerConnectionProfile({
2263 name: profile.name,
2264 connection: {
2265 kind: profile.connection.kind,
2266 origin: profile.connection.origin,
2267 displayName: profile.connection.displayName,
2268 },
2269 makeDefault: store.defaultProfile === profile.name,
2270 });
2271 console.log(
2272 `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").`,
2273 );
2274 }),
2275).pipe(Command.withDescription("Clear stored credentials for a server profile"));
2276
2277const whoamiCommand = Command.make(
2278 "whoami",
2279 { baseUrl: serverBaseUrl, server: serverProfile },
2280 ({ baseUrl, server }) =>
2281 Effect.gen(function* () {
2282 const requested = yield* resolveRequestedExecutorServerConnection(
2283 serverTargetFromOptions({ baseUrl, server }),
2284 );
2285 const connection = yield* refreshOAuthConnection(requested.connection);
2286 const auth = connection.auth;
2287 console.log(`Server: ${connection.origin}`);
2288 if (!auth) {
2289 console.log("Not logged in (no stored credentials).");
2290 return;
2291 }
2292 if (auth.kind === "oauth") {
2293 const claims = decodeAccessTokenClaims(auth.accessToken);
2294 const sub = claims?.sub;
2295 const org = claims?.org_id;
2296 // The email lives on the profile's displayName (WorkOS access tokens
2297 // don't carry an email claim); the token carries sub + org_id.
2298 if (connection.displayName.includes("@")) console.log(`Account: ${connection.displayName}`);
2299 if (typeof sub === "string") console.log(`User: ${sub}`);
2300 if (typeof org === "string") console.log(`Organization: ${org}`);
2301 if (auth.expiresAt) {
2302 const remaining = auth.expiresAt - Math.floor(Date.now() / 1000);
2303 console.log(
2304 remaining > 0
2305 ? `Token valid for ~${Math.round(remaining / 60)} min.`
2306 : "Token expired (refreshes on next use).",
2307 );
2308 }
2309 return;
2310 }
2311 console.log(`Authenticated via ${auth.kind}.`);
2312 }),
2313).pipe(Command.withDescription("Show who you're signed in as on a server"));
2314
1998const webCommand = Command.make(2315const webCommand = Command.make(
1999 "web",2316 "web",
2000 {2317 {
@@ -2562,6 +2879,9 @@ const root = Command.make("executor").pipe( · 561 unmodified lines
2562 resumeCommand,2879 resumeCommand,
2563 toolsCommand,2880 toolsCommand,
2564 installCommand,2881 installCommand,
2882 loginCommand,
2883 logoutCommand,
2884 whoamiCommand,
2565 serverCommand,2885 serverCommand,
2566 webCommand,2886 webCommand,
2567 daemonCommand,2887 daemonCommand,
apps/cli/src/server-connection.ts+4−2
@@ -43,8 +43,10 @@ export const withCliServerAuthFallback = ( · 42 unmodified lines
43 43
44export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => {44export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => {
45 if (connection.kind !== "http") return false;45 if (connection.kind !== "http") return false;
46 // Explicit auth means the user is pointing at an existing server (e.g. a46 // A stored credential (basic password, bearer key, or an oauth device-login
47 // desktop sidecar or a remote), not asking us to spin up a local daemon.47 // token) means the user is targeting an EXISTING authenticated server, never
48 // spawn a local daemon for it, even when it happens to be on http://localhost
49 // (e.g. a hosted server reached over a tunnel, or a local e2e cloud server).
48 if (connection.auth) return false;50 if (connection.auth) return false;
49 const url = new URL(connection.origin);51 const url = new URL(connection.origin);
50 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);52 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);
apps/cli/src/server-profile.ts+8−0
@@ -52,6 +52,14 @@ const PersistedAuth = Schema.Union([ · 51 unmodified lines
52 kind: Schema.Literal("bearer"),52 kind: Schema.Literal("bearer"),
53 token: Schema.String,53 token: Schema.String,
54 }),54 }),
55 Schema.Struct({
56 kind: Schema.Literal("oauth"),
57 accessToken: Schema.String,
58 refreshToken: Schema.optional(Schema.String),
59 expiresAt: Schema.optional(Schema.Number),
60 tokenEndpoint: Schema.optional(Schema.String),
61 clientId: Schema.optional(Schema.String),
62 }),
55]);63]);
56 64
57const PersistedConnection = Schema.Struct({65const PersistedConnection = Schema.Struct({
apps/cloud/src/api/protected-jwt-auth.node.test.ts+169−0
@@ -0,0 +1,169 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose";
4
5import { ApiKeyService } from "../auth/api-keys";
6import { UserStoreService } from "../auth/context";
7import type { JwtBearerConfig } from "../auth/workos-auth-provider";
8import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
9import { resolveProtectedPrincipal } from "./protected";
10
11const createdAt = new Date("2026-01-01T00:00:00.000Z");
12const issuer = "https://test-authkit.example.com";
13const audience = "client_test_audience";
14
15// A WorkOS access-token JWT verifier backed by a local RS256 key pair (no
16// network), plus a signer that mints tokens for it. Mirrors
17// `mcp/mcp-auth.node.test.ts`.
18const makeJwt = async () => {
19 const { publicKey, privateKey } = await generateKeyPair("RS256");
20 const jwk = await exportJWK(publicKey);
21 const jwks = createLocalJWKSet({ keys: [{ ...jwk, kid: "test-key" }] });
22 const config: JwtBearerConfig = { jwks, issuer, audience };
23 const sign = (claims: Record<string, unknown>, expiration: string | number = "5m") =>
24 new SignJWT(claims)
25 .setProtectedHeader({ alg: "RS256", kid: "test-key" })
26 .setIssuer(issuer)
27 .setAudience(audience)
28 .setSubject("user_123")
29 .setIssuedAt()
30 .setExpirationTime(expiration)
31 .sign(privateKey);
32 return { config, sign };
33};
34
35// The api-key path must never run for a JWT bearer, die loudly if it does.
36const stubApiKeys = Layer.succeed(ApiKeyService)({
37 validate: () => Effect.die("JWT bearer must not hit ApiKeyService.validate"),
38 listUserKeys: () => Effect.succeed([]),
39 createUserKey: () => Effect.die("JWT auth test does not create API keys"),
40 revokeUserKey: () => Effect.void,
41});
42
43const stubWorkOS = Layer.succeed(
44 WorkOSClient,
45 new Proxy({} as WorkOSClientService, {
46 get: (_target, prop) => {
47 if (prop === "listUserMemberships") {
48 return (userId: string) =>
49 Effect.succeed({
50 data:
51 userId === "user_123"
52 ? [{ userId, organizationId: "org_123", status: "active" }]
53 : [],
54 });
55 }
56 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
57 },
58 }),
59);
60
61const stubUsers = Layer.succeed(UserStoreService)({
62 use: (fn) =>
63 Effect.promise(() =>
64 fn({
65 ensureAccount: async (id: string) => ({ id, createdAt }),
66 getAccount: async (id: string) => ({ id, createdAt }),
67 upsertOrganization: async (org: { id: string; name: string }) => ({
68 ...org,
69 slug: `org-slug-${org.id}`,
70 createdAt,
71 }),
72 getOrganization: async (id: string) => ({
73 id,
74 name: `Org ${id}`,
75 slug: `org-slug-${id}`,
76 createdAt,
77 }),
78 getOrganizationBySlug: async (slug: string) => ({
79 id: "org_by_slug",
80 name: `Org ${slug}`,
81 slug,
82 createdAt,
83 }),
84 }),
85 ),
86});
87
88const run = (request: Request, jwt: JwtBearerConfig) =>
89 resolveProtectedPrincipal(request, jwt).pipe(
90 Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)),
91 );
92
93const request = (token: string) =>
94 new Request("https://executor.test/api/tools", {
95 headers: { authorization: `Bearer ${token}` },
96 });
97
98describe("protected JWT (device-login) auth", () => {
99 it.effect("resolves a valid WorkOS access token into protected identity", () =>
100 Effect.gen(function* () {
101 const { config, sign } = yield* Effect.promise(() => makeJwt());
102 const token = yield* Effect.promise(() => sign({ org_id: "org_123" }));
103
104 const identity = yield* run(request(token), config);
105
106 expect(identity).toEqual({
107 accountId: "user_123",
108 organizationId: "org_123",
109 organizationName: "Org org_123",
110 email: "",
111 name: null,
112 avatarUrl: null,
113 roles: [],
114 });
115 }),
116 );
117
118 it.effect("rejects an expired access token as Unauthorized", () =>
119 Effect.gen(function* () {
120 const { config, sign } = yield* Effect.promise(() => makeJwt());
121 // Expired one hour ago.
122 const token = yield* Effect.promise(() =>
123 sign({ org_id: "org_123" }, Math.floor(Date.now() / 1000) - 3600),
124 );
125
126 const error = yield* Effect.flip(run(request(token), config));
127
128 expect(error).toMatchObject({
129 _tag: "Unauthorized",
130 code: "invalid_access_token",
131 });
132 }),
133 );
134
135 it.effect("rejects a token signed by a different key as Unauthorized", () =>
136 Effect.gen(function* () {
137 const { config } = yield* Effect.promise(() => makeJwt());
138 const other = yield* Effect.promise(() => makeJwt());
139 const token = yield* Effect.promise(() => other.sign({ org_id: "org_123" }));
140
141 const error = yield* Effect.flip(run(request(token), config));
142
143 expect(error).toMatchObject({ _tag: "Unauthorized", code: "invalid_access_token" });
144 }),
145 );
146
147 it.effect("rejects a token with no org_id as NoOrganization", () =>
148 Effect.gen(function* () {
149 const { config, sign } = yield* Effect.promise(() => makeJwt());
150 const token = yield* Effect.promise(() => sign({}));
151
152 const error = yield* Effect.flip(run(request(token), config));
153
154 expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" });
155 }),
156 );
157
158 it.effect("rejects a valid token whose org membership is not active", () =>
159 Effect.gen(function* () {
160 const { config, sign } = yield* Effect.promise(() => makeJwt());
161 // org_999 is not in the membership stub -> not authorized.
162 const token = yield* Effect.promise(() => sign({ org_id: "org_999" }));
163
164 const error = yield* Effect.flip(run(request(token), config));
165
166 expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" });
167 }),
168 );
169});
apps/cloud/src/auth/api-jwt-bearer.ts+35−0
@@ -0,0 +1,35 @@
1// ---------------------------------------------------------------------------
2// The `/api/*` plane's WorkOS JWT bearer config.
3//
4// The protected HTTP API accepts THREE credential forms on the `Authorization`
5// header, in this precedence: a WorkOS access-token JWT (CLI device-login,
6// `executor login`), then a WorkOS API key, then, with no header, the
7// sealed-session cookie. This module supplies the JWT-verification config
8// (JWKS + issuer + audience) the JWT branch needs.
9//
10// It mirrors the MCP plane's setup (`mcp/auth.ts`): the SAME AuthKit JWKS,
11// issuer (`MCP_AUTHKIT_DOMAIN`), and audience (`WORKOS_CLIENT_ID`). A device
12// access token minted by AuthKit is byte-for-byte the same kind of token the
13// MCP plane already verifies, so a CLI can hold ONE credential for both planes.
14//
15// This is the `cloudflare:workers`-reading leaf; `workos-auth-provider.ts`
16// stays env-free and receives this config as a plain value, so its node-pool
17// resolver tests can inject a local JWKS instead.
18// ---------------------------------------------------------------------------
19
20import { env } from "cloudflare:workers";
21
22import { createCachedRemoteJWKSet } from "./jwks-cache";
23import type { JwtBearerConfig } from "./workos-auth-provider";
24
25const AUTHKIT_DOMAIN = env.MCP_AUTHKIT_DOMAIN ?? "https://signin.executor.sh";
26
27// Module-scope cache, same rationale as `mcp/auth.ts`: one JWKS fetch per
28// isolate-hour rather than one per cold isolate. The two caches (here + MCP)
29// are independent module scopes hitting the same upstream; the 1h TTL keeps
30// the duplication cheap.
31export const workosApiJwtBearerConfig: JwtBearerConfig = {
32 jwks: createCachedRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`)),
33 issuer: AUTHKIT_DOMAIN,
34 audience: env.WORKOS_CLIENT_ID,
35};
apps/cloud/src/auth/api.ts+13−1
@@ -44,6 +44,17 @@ const CreateOrganizationResponse = Schema.Struct({ · 43 unmodified lines
44 slug: Schema.String,44 slug: Schema.String,
45});45});
46 46
47// CLI device-login discovery (`executor login`). Tells the CLI where to run
48// the OAuth 2.0 Device Authorization Grant (RFC 8628) and which public client
49// to use. The CLI hits these provider endpoints directly, gets a WorkOS access
50// token (a JWT), and sends it as a Bearer to the `/api/*` plane.
51const CliLoginResponse = Schema.Struct({
52 provider: Schema.Literal("workos"),
53 deviceAuthorizationEndpoint: Schema.String,
54 tokenEndpoint: Schema.String,
55 clientId: Schema.String,
56});
57
47// `state` is optional — some WorkOS-initiated redirects arrive at the58// `state` is optional — some WorkOS-initiated redirects arrive at the
48// callback without the state we set on /auth/login. The CSRF check is59// callback without the state we set on /auth/login. The CSRF check is
49// only enforced when state is present (see callback handler).60// only enforced when state is present (see callback handler).
@@ -156,7 +167,8 @@ export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") · 106 unmodified lines
156 query: AuthCallbackSearch,167 query: AuthCallbackSearch,
157 error: AuthErrors,168 error: AuthErrors,
158 }),169 }),
159 ) {}170 )
171 .add(HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { success: CliLoginResponse })) {}
160 172
161/** Session auth endpoints — require a logged-in user, may not have an org */173/** Session auth endpoints — require a logged-in user, may not have an org */
162export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")174export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
apps/cloud/src/auth/handlers.ts+16−0
@@ -236,6 +236,22 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 235 unmodified lines
236 STATE_COOKIE,236 STATE_COOKIE,
237 );237 );
238 }),238 }),
239 )
240 // CLI device-login discovery. The WorkOS device endpoints live on the
241 // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production,
242 // the SAME base the SDK uses, so e2e points the CLI at the emulator with
243 // zero extra wiring). The CLI runs RFC 8628 against them as a public
244 // client (no secret) and gets a WorkOS access-token JWT back.
245 .handle("cliLogin", () =>
246 Effect.sync(() => {
247 const base = (env.WORKOS_API_URL ?? "https://api.workos.com").replace(/\/+$/, "");
248 return {
249 provider: "workos" as const,
250 deviceAuthorizationEndpoint: `${base}/user_management/authorize/device`,
251 tokenEndpoint: `${base}/user_management/authenticate`,
252 clientId: env.WORKOS_CLIENT_ID,
253 };
254 }),
239 ),255 ),
240);256);
241 257
apps/cloud/src/auth/workos-auth-provider.ts+90−4
@@ -29,6 +29,7 @@ · 28 unmodified lines
29 29
30import { Effect, Layer } from "effect";30import { Effect, Layer } from "effect";
31import { HttpServerResponse } from "effect/unstable/http";31import { HttpServerResponse } from "effect/unstable/http";
32import type { JWTVerifyGetKey } from "jose";
32 33
33import {34import {
34 IdentityProvider,35 IdentityProvider,
@@ -39,6 +40,7 @@ import { · 4 unmodified lines
39import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server";40import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server";
40 41
41import { ApiKeyService } from "./api-keys";42import { ApiKeyService } from "./api-keys";
43import { workosApiJwtBearerConfig } from "./api-jwt-bearer";
42import { BEARER_PREFIX } from "./bearer";44import { BEARER_PREFIX } from "./bearer";
43import {45import {
44 authorizeOrganization,46 authorizeOrganization,
@@ -49,6 +51,20 @@ import { UserStoreService } from "./context"; · 4 unmodified lines
49import { sealedSessionDisplayName } from "./middleware";51import { sealedSessionDisplayName } from "./middleware";
50import type { UserStoreError, WorkOSError } from "./errors";52import type { UserStoreError, WorkOSError } from "./errors";
51import { WorkOSClient } from "./workos";53import { WorkOSClient } from "./workos";
54import { verifyWorkOSMcpAccessToken } from "../mcp/jwt";
55
56/**
57 * The config the bearer-JWT branch needs to verify a WorkOS access token:
58 * the AuthKit JWKS resolver plus the issuer + audience to assert. Passed in
59 * as a plain value so this module stays `cloudflare:workers`-free and the
60 * node-pool resolver tests can inject a local JWKS. Production supplies
61 * {@link workosApiJwtBearerConfig} (built from `cloudflare:workers` env).
62 */
63export interface JwtBearerConfig {
64 readonly jwks: JWTVerifyGetKey;
65 readonly issuer: string;
66 readonly audience: string;
67}
52 68
53// The exact machine codes + messages each rejected path has always emitted.69// The exact machine codes + messages each rejected path has always emitted.
54// Carried on the shared identity error so the failure strategy renders the70// Carried on the shared identity error so the failure strategy renders the
@@ -70,8 +86,75 @@ const NO_ORGANIZATION_IN_SESSION = { · 15 unmodified lines
70 code: "no_organization",86 code: "no_organization",
71 message: "No organization in session",87 message: "No organization in session",
72};88};
89const INVALID_ACCESS_TOKEN = {
90 code: "invalid_access_token",
91 message: "Invalid or expired access token",
92};
93const ACCESS_TOKEN_VERIFICATION_UNAVAILABLE = {
94 code: "access_token_verification_unavailable",
95 message: "Access token verification is temporarily unavailable",
96};
97const NO_ORGANIZATION_IN_ACCESS_TOKEN = {
98 code: "no_organization",
99 message: "No organization in access token",
100};
101
102// A bearer value with three dot-separated segments is a JWT (a WorkOS access
103// token from the CLI device-login); anything else is treated as an API key.
104// Same discriminator the MCP plane uses (`mcp/auth.ts`).
105const looksLikeJwt = (token: string): boolean => token.split(".").length === 3;
73 106
74export const resolveApiKeyPrincipal = (request: Request) =>107/**
108 * Resolve a WorkOS access-token JWT (CLI `executor login`) into a protected
109 * `Principal`. Verifies the token against AuthKit's JWKS (issuer + audience),
110 * then live-checks org membership, exactly like the api-key path. The
111 * `org_id` claim must be present (a token with no org context is rejected as
112 * `NoOrganization`). Mirrors the MCP plane's JWT verify, reusing the same
113 * `cloudflare:workers`-free verifier so a CLI holds ONE credential for both
114 * the `/api/*` and `/mcp` planes.
115 */
116const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) =>
117 Effect.gen(function* () {
118 const verified = yield* verifyWorkOSMcpAccessToken(token, jwt.jwks, {
119 issuer: jwt.issuer,
120 audience: jwt.audience,
121 }).pipe(
122 Effect.catchTag("McpJwtVerificationError", (error) =>
123 Effect.fail(
124 error.reason === "system"
125 ? new Unavailable(ACCESS_TOKEN_VERIFICATION_UNAVAILABLE)
126 : new Unauthorized(INVALID_ACCESS_TOKEN),
127 ),
128 ),
129 );
130
131 if (!verified || !verified.accountId) return yield* new Unauthorized(INVALID_ACCESS_TOKEN);
132 if (!verified.organizationId) {
133 return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);
134 }
135
136 const org = yield* authorizeOrganization(verified.accountId, verified.organizationId);
137 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);
138
139 return {
140 accountId: verified.accountId,
141 organizationId: org.id,
142 organizationName: org.name,
143 email: "",
144 name: null,
145 avatarUrl: null,
146 roles: [],
147 } satisfies Principal;
148 });
149
150/**
151 * Resolve a `Bearer` credential into a `Principal`: a WorkOS access-token JWT
152 * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS
153 * API key. Returns `null` when there is no `Authorization` header, so the
154 * caller falls through to the sealed-session path. (Kept the historical name,
155 * the re-export and resolver tests reference it.)
156 */
157export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) =>
75 Effect.gen(function* () {158 Effect.gen(function* () {
76 const authHeader = request.headers.get("authorization");159 const authHeader = request.headers.get("authorization");
77 if (!authHeader) return null;160 if (!authHeader) return null;
@@ -83,6 +166,8 @@ export const resolveApiKeyPrincipal = (request: Request) => · 5 unmodified lines
83 const value = authHeader.slice(BEARER_PREFIX.length).trim();166 const value = authHeader.slice(BEARER_PREFIX.length).trim();
84 if (!value) return yield* new Unauthorized(INVALID_API_KEY);167 if (!value) return yield* new Unauthorized(INVALID_API_KEY);
85 168
169 if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt);
170
86 const apiKeys = yield* ApiKeyService;171 const apiKeys = yield* ApiKeyService;
87 const principal = yield* apiKeys172 const principal = yield* apiKeys
88 .validate(value)173 .validate(value)
@@ -149,14 +234,15 @@ export const resolveSessionPrincipal = (request: Request) => · 60 unmodified lines
149 */234 */
150export const resolveProtectedPrincipal = (235export const resolveProtectedPrincipal = (
151 request: Request,236 request: Request,
237 jwt: JwtBearerConfig | null = null,
152): Effect.Effect<238): Effect.Effect<
153 Principal,239 Principal,
154 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,240 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
155 WorkOSClient | ApiKeyService | UserStoreService241 WorkOSClient | ApiKeyService | UserStoreService
156> =>242> =>
157 Effect.gen(function* () {243 Effect.gen(function* () {
158 const apiKeyPrincipal = yield* resolveApiKeyPrincipal(request);244 const bearerPrincipal = yield* resolveApiKeyPrincipal(request, jwt);
159 if (apiKeyPrincipal) return apiKeyPrincipal;245 if (bearerPrincipal) return bearerPrincipal;
160 return yield* resolveSessionPrincipal(request);246 return yield* resolveSessionPrincipal(request);
161 });247 });
162 248
@@ -180,7 +266,7 @@ export const workosIdentityLayer: Layer.Layer< · 17 unmodified lines
180 const context = yield* Effect.context<WorkOSClient | ApiKeyService | UserStoreService>();266 const context = yield* Effect.context<WorkOSClient | ApiKeyService | UserStoreService>();
181 return IdentityProvider.of({267 return IdentityProvider.of({
182 authenticate: (request) =>268 authenticate: (request) =>
183 resolveProtectedPrincipal(request).pipe(269 resolveProtectedPrincipal(request, workosApiJwtBearerConfig).pipe(
184 // `UserStoreError` / `WorkOSError` are org-resolution infra failures —270 // `UserStoreError` / `WorkOSError` are org-resolution infra failures —
185 // surface as a 500 defect, exactly as the old inline resolver let them271 // surface as a 500 defect, exactly as the old inline resolver let them
186 // bubble. The narrow `die` here is the runtime edge for that infra272 // bubble. The narrow `die` here is the runtime edge for that infra
apps/host-selfhost/src/app.ts+23−1
@@ -73,6 +73,24 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 72 unmodified lines
73 // a reverse proxy (not the internal 127.0.0.1 bind from the request URL).73 // a reverse proxy (not the internal 127.0.0.1 bind from the request URL).
74 const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl);74 const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl);
75 75
76 // CLI device-login discovery (`executor login`). Points the CLI at Better
77 // Auth's device endpoints; `requestFormat: "json"` because those endpoints
78 // only accept JSON (unlike WorkOS's form-encoded ones). The issued token is a
79 // Better Auth session that `bearer()` accepts on the /api/* plane.
80 const cliLoginHandler = HttpEffect.fromWebHandler(
81 async () =>
82 new Response(
83 JSON.stringify({
84 provider: "better-auth",
85 deviceAuthorizationEndpoint: `${config.webBaseUrl}/api/auth/device/code`,
86 tokenEndpoint: `${config.webBaseUrl}/api/auth/device/token`,
87 clientId: "executor-cli",
88 requestFormat: "json",
89 }),
90 { headers: { "content-type": "application/json" } },
91 ),
92 );
93
76 const { appLayer, toWebHandler } = ExecutorApp.make({94 const { appLayer, toWebHandler } = ExecutorApp.make({
77 plugins: selfHostPlugins,95 plugins: selfHostPlugins,
78 providers: {96 providers: {
@@ -86,7 +104,11 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 7 unmodified lines
86 },104 },
87 extensions: {105 extensions: {
88 routes: [106 routes: [
89 // Better Auth owns /api/auth/* — the full path reaches it unmodified.107 // CLI device-login discovery, must precede the /api/auth/* wildcard
108 // below (Better Auth would otherwise 404 it). The verification page it
109 // points at (/device) is a console SPA route (web/device.tsx).
110 HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler),
111 // Better Auth owns the rest of /api/auth/*, the full path reaches it.
90 HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)),112 HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)),
91 // Browser approval of paused MCP executions: the console resume page113 // Browser approval of paused MCP executions: the console resume page
92 // reads paused detail (GET) and records the decision (POST .../resume),114 // reads paused detail (GET) and records the decision (POST .../resume),
apps/host-selfhost/src/auth/better-auth.ts+9−1
@@ -1,6 +1,6 @@
1import { betterAuth, type BetterAuthOptions } from "better-auth";1import { betterAuth, type BetterAuthOptions } from "better-auth";
2import { APIError } from "better-auth/api";2import { APIError } from "better-auth/api";
3import { admin, bearer, mcp, organization } from "better-auth/plugins";3import { admin, bearer, deviceAuthorization, mcp, organization } from "better-auth/plugins";
4import { apiKey } from "@better-auth/api-key";4import { apiKey } from "@better-auth/api-key";
5import { type Client } from "@libsql/client";5import { type Client } from "@libsql/client";
6import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql";6import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql";
@@ -114,6 +114,14 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 107 unmodified lines
114 admin(),114 admin(),
115 apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),115 apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
116 bearer(),116 bearer(),
117 // RFC 8628 device authorization, the CLI `executor login` flow. Registers
118 // /device/code + /device/token + the approval endpoints; the issued token
119 // is an opaque session that `bearer()` (above) accepts as `Authorization:
120 // Bearer` on the /api/* plane. `validateClient` is left unset, so any
121 // client_id is accepted (the CLI presents "executor-cli"). `verificationUri`
122 // is the page the user opens to confirm the code — the self-host app serves
123 // it at /device (this is also the Better Auth default; pinned for clarity).
124 deviceAuthorization({ verificationUri: "/device" }),
117 // `consentPage` makes the MCP authorize flow redirect to a human approval125 // `consentPage` makes the MCP authorize flow redirect to a human approval
118 // screen instead of auto-issuing a code — but ONLY when the request126 // screen instead of auto-issuing a code — but ONLY when the request
119 // carries `prompt=consent`. MCP clients don't send that, so the self-host127 // carries `prompt=consent`. MCP clients don't send that, so the self-host
apps/host-selfhost/web/device.tsx+137−0
@@ -0,0 +1,137 @@
1import { useEffect, useState } from "react";
2
3import { Button } from "@executor-js/react/components/button";
4
5// ---------------------------------------------------------------------------
6// CLI device-login verification page. Better Auth's deviceAuthorization()
7// plugin ships the JSON device API but no UI, so this is the page its
8// `verificationUri` (/device) points at. `executor login` prints the code and
9// opens this URL; the user confirms it and authorizes the device.
10//
11// Flow: on load we claim the pending code for the signed-in session (GET
12// /api/auth/device?user_code, the plugin's bind step), then Approve/Deny POST
13// to /api/auth/device/{approve,deny}. Like McpConsentPage, this is rendered
14// chromeless INSIDE the auth gate (so the user is already signed in) via a
15// static import in routes/__root.tsx, and drives the endpoints with plain fetch
16// (the same convention the MCP consent screen uses).
17// ---------------------------------------------------------------------------
18
19type Phase = "binding" | "ready" | "approved" | "denied" | "error";
20
21export const DevicePage = () => {
22 const params = new URLSearchParams(globalThis.window?.location.search ?? "");
23 const userCode = params.get("user_code") ?? "";
24 const [phase, setPhase] = useState<Phase>("binding");
25 const [busy, setBusy] = useState<"approve" | "deny" | null>(null);
26 const [error, setError] = useState<string | null>(null);
27
28 useEffect(() => {
29 if (!userCode) {
30 setPhase("error");
31 setError("This link is missing its device code.");
32 return;
33 }
34 let alive = true;
35 // Claim the pending code for this session; only the same session can then
36 // approve or deny it.
37 const bind = async () => {
38 const res = await fetch(
39 `${globalThis.location.origin}/api/auth/device?user_code=${encodeURIComponent(userCode)}`,
40 { credentials: "include", headers: { accept: "application/json" } },
41 );
42 if (!alive) return;
43 if (res.ok) {
44 setPhase("ready");
45 } else {
46 setPhase("error");
47 setError("This device code is unknown, already used, or expired.");
48 }
49 };
50 void bind();
51 return () => {
52 alive = false;
53 };
54 }, [userCode]);
55
56 const decide = async (accept: boolean) => {
57 setBusy(accept ? "approve" : "deny");
58 setError(null);
59 const res = await fetch(
60 `${globalThis.location.origin}/api/auth/device/${accept ? "approve" : "deny"}`,
61 {
62 method: "POST",
63 credentials: "include",
64 headers: { "content-type": "application/json" },
65 body: JSON.stringify({ userCode }),
66 },
67 );
68 setBusy(null);
69 if (!res.ok) {
70 setError("Could not record your decision. The code may have expired, try again.");
71 return;
72 }
73 setPhase(accept ? "approved" : "denied");
74 };
75
76 return (
77 <div className="flex min-h-screen items-center justify-center bg-background p-6">
78 <div
79 id="device-approval"
80 className="w-full max-w-md space-y-5 rounded-xl border border-border bg-card p-6 text-center shadow-sm"
81 >
82 <div className="space-y-1">
83 <h1 className="font-display text-2xl tracking-tight text-foreground">
84 Authorize this device
85 </h1>
86 <p className="text-sm text-muted-foreground">Confirm the code shown in your terminal.</p>
87 </div>
88
89 <div className="rounded-lg border border-border bg-background/50 p-4 font-mono text-2xl tracking-[0.3em] text-foreground">
90 {userCode || "XXXX-XXXX"}
91 </div>
92
93 {phase === "binding" && <p className="text-sm text-muted-foreground">Checking the code…</p>}
94
95 {phase === "ready" && (
96 <>
97 <p className="text-xs text-muted-foreground">
98 A signed-in device will be able to act as your account through the CLI.
99 </p>
100 {error && <p className="text-sm text-destructive">{error}</p>}
101 <div className="flex gap-3">
102 <Button
103 id="device-deny"
104 type="button"
105 variant="outline"
106 className="flex-1"
107 disabled={busy !== null}
108 onClick={() => void decide(false)}
109 >
110 {busy === "deny" ? "Denying…" : "Deny"}
111 </Button>
112 <Button
113 id="device-approve"
114 type="button"
115 className="flex-1"
116 disabled={busy !== null}
117 onClick={() => void decide(true)}
118 >
119 {busy === "approve" ? "Authorizing…" : "Authorize device"}
120 </Button>
121 </div>
122 </>
123 )}
124
125 {phase === "approved" && (
126 <p className="text-sm text-foreground">Device approved. Return to your terminal.</p>
127 )}
128 {phase === "denied" && (
129 <p className="text-sm text-muted-foreground">
130 Request denied. You can close this window.
131 </p>
132 )}
133 {phase === "error" && <p className="text-sm text-destructive">{error}</p>}
134 </div>
135 </div>
136 );
137};
apps/host-selfhost/web/routes/__root.tsx+15−0
@@ -11,6 +11,7 @@ import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shel · 10 unmodified lines
11import { plugins as clientPlugins } from "virtual:executor/plugins-client";11import { plugins as clientPlugins } from "virtual:executor/plugins-client";
12 12
13import { authClient } from "../auth-client";13import { authClient } from "../auth-client";
14import { DevicePage } from "../device";
14import { McpConsentPage } from "../mcp-consent";15import { McpConsentPage } from "../mcp-consent";
15import { LoginPage } from "../login";16import { LoginPage } from "../login";
16import { SetupPage } from "../setup";17import { SetupPage } from "../setup";
@@ -127,6 +128,20 @@ function RootComponent() { · 110 unmodified lines
127 );128 );
128 }129 }
129 130
131 // The CLI device-login verification page: chromeless, inside the auth gate
132 // (the user signs in here if needed, then authorizes the code). Same
133 // static-import + pathname-branch convention as /mcp-consent.
134 if (pathname === "/device") {
135 return (
136 <AuthProvider>
137 <AuthGate>
138 <DevicePage />
139 <Toaster />
140 </AuthGate>
141 </AuthProvider>
142 );
143 }
144
130 return (145 return (
131 <AuthProvider>146 <AuthProvider>
132 <AuthGate>147 <AuthGate>
e2e/cloud/cli-device-login.test.ts+223−0
@@ -0,0 +1,223 @@
1// Cloud: the CLI `executor login` device-authorization flow end to end against
2// the real cloud app, terminal AND browser, both recorded for the viewer. The
3// actual `executor` binary runs the OAuth 2.0 Device Authorization Grant
4// (RFC 8628) in a real terminal (terminal.cast): it prints the verification URL
5// and polls. The browser leg is REAL too (session.mp4): Playwright opens that
6// URL, confirms the code, and clicks "Authorize device" on the WorkOS
7// emulator's verification page, exactly the human hop, the way the MCP
8// approval scenarios drive their browser step. The terminal then runs `whoami`
9// and `tools sources`; a clean exit of that chain proves the resulting WorkOS
10// access token (a JWT) is accepted by the protected `/api/*` plane.
11import { spawn } from "node:child_process";
12import { readFileSync } from "node:fs";
13import { dirname, join, resolve } from "node:path";
14import { fileURLToPath } from "node:url";
15
16import { expect } from "@effect/vitest";
17import { Effect } from "effect";
18
19import { scenario } from "../src/scenario";
20import { Browser, Cli, RunDir, Target } from "../src/services";
21import { enterFocus } from "../src/timeline";
22import { CLOUD_BASE_URL } from "../targets/cloud";
23
24const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
25const CLI_ENTRY = join(REPO_ROOT, "apps", "cli", "src", "main.ts");
26
27scenario(
28 "CLI · executor login device flow → authenticated /api call",
29 { timeout: 180_000 },
30 Effect.scoped(
31 Effect.gen(function* () {
32 const target = yield* Target;
33 // Cloud-only: the discovery endpoint + WorkOS device flow are this target's.
34 if (target.name !== "cloud") return;
35
36 // Slow + hold the browser steps so the recording is watchable. Scoped to
37 // this scenario and restored after (files run serially, so a leaked flag
38 // would slow every later scenario).
39 const prevFilm = process.env.E2E_FILM;
40 yield* Effect.addFinalizer(() =>
41 Effect.sync(() => {
42 if (prevFilm === undefined) delete process.env.E2E_FILM;
43 else process.env.E2E_FILM = prevFilm;
44 }),
45 );
46 process.env.E2E_FILM = "1";
47
48 const cli = yield* Cli;
49 const browser = yield* Browser;
50 const runDir = yield* RunDir;
51 const dataDir = join(runDir, "cli-home");
52
53 // A fresh signed-in user with an org, the org is what the device token's
54 // org_id claim binds to, and what the /api plane authorizes against.
55 const identity = yield* target.newIdentity();
56 const email = identity.credentials?.email ?? identity.label;
57
58 const env = { ...process.env, EXECUTOR_DATA_DIR: dataDir };
59 for (const key of ["EXECUTOR_API_KEY", "EXECUTOR_AUTH_TOKEN", "EXECUTOR_AUTH_PASSWORD"]) {
60 delete (env as Record<string, string | undefined>)[key];
61 }
62
63 // Hand the printed verification URL from the terminal fiber to the browser.
64 let resolveUrl!: (url: string) => void;
65 const verificationUrl = new Promise<string>((r) => {
66 resolveUrl = r;
67 });
68
69 // The terminal journey, recorded to terminal.cast. `&&` means a clean
70 // exit only happens if every step, including the authenticated /api call
71 // (`tools sources`), succeeded.
72 const cli_ = `bun run ${CLI_ENTRY}`;
73 const journey =
74 `${cli_} login --base-url ${CLOUD_BASE_URL} --no-browser --name cloud && ` +
75 `${cli_} whoami --server cloud && ` +
76 `${cli_} tools sources --server cloud`;
77
78 const terminal = cli.session(
79 ["bash", "-c", journey],
80 async (session) => {
81 await session.screen.waitForText(/user_code=/, { timeoutMs: 60_000 });
82 const match = (await session.screen.text()).match(/(https?:\/\/\S*user_code=\S+)/);
83 if (!match) throw new Error("verification URL not found on screen");
84 resolveUrl(match[1]);
85 await session.screen.waitForText("Logged in to", { timeoutMs: 60_000 });
86 const exit = await session.waitForExit({ timeoutMs: 60_000 });
87 if (exit.reason !== "exited" || exit.exit.code !== 0) {
88 throw new Error(
89 `journey did not exit cleanly: ${JSON.stringify(exit)}\n${await session.screen.text()}`,
90 );
91 }
92 return session.screen.text();
93 },
94 {
95 cwd: REPO_ROOT,
96 env,
97 record: join(runDir, "terminal.cast"),
98 viewport: { cols: 300, rows: 48 },
99 },
100 );
101
102 // The browser leg, a REAL Playwright session approving the device on the
103 // verification page (recorded to session.mp4 + per-step screenshots). Runs
104 // concurrently with the terminal: it waits for the printed URL, then
105 // approves while the CLI is mid-poll.
106 const browserApproval = Effect.gen(function* () {
107 const url = yield* Effect.promise(() => verificationUrl);
108 yield* browser.session(identity, async ({ page, step }) => {
109 await step("Open the device verification link from the terminal", async () => {
110 await page.goto(url, { waitUntil: "domcontentloaded" });
111 await page
112 .getByRole("button", { name: /Authorize device/i })
113 .waitFor({ timeout: 15_000 });
114 });
115 await step("Confirm the code and authorize the device", async () => {
116 // The visible email field (existing-user quick buttons also carry a
117 // hidden name="email", so target the typed input by type).
118 await page.locator('input[type="email"]').fill(email);
119 await page.getByRole("button", { name: /Authorize device/i }).click();
120 await page.getByText(/Device approved/i).waitFor({ timeout: 15_000 });
121 });
122 });
123 // Cut the synced player back to the terminal for the "Logged in" + the
124 // authenticated /api call that follow the browser approval.
125 yield* Effect.promise(() => enterFocus(runDir, "terminal"));
126 });
127
128 const [finalScreen] = yield* Effect.all([terminal, browserApproval], {
129 concurrency: "unbounded",
130 });
131 expect(finalScreen, "whoami reported the bound organization").toMatch(/org_\w+/);
132
133 // The stored profile carries an oauth device-login credential, not a key.
134 const store = JSON.parse(readFileSync(join(dataDir, "server-connections.json"), "utf8")) as {
135 defaultProfile: string | null;
136 profiles: Array<{
137 name: string;
138 connection: { auth?: { kind: string; accessToken?: string } };
139 }>;
140 };
141 expect(store.defaultProfile, "the login became the default profile").toBe("cloud");
142 const cloudProfile = store.profiles.find((p) => p.name === "cloud");
143 expect(cloudProfile?.connection.auth?.kind, "credential is an oauth device token").toBe(
144 "oauth",
145 );
146 expect(typeof cloudProfile?.connection.auth?.accessToken, "an access token is stored").toBe(
147 "string",
148 );
149 }),
150 ),
151);
152
153// Run `executor login` as a subprocess, approving the device for `approveEmail`
154// the moment the verification URL is printed (raw stdout, no PTY).
155const runCliLogin = (
156 args: readonly string[],
157 dataDir: string,
158 approveEmail: string,
159): Promise<{ code: number | null; stdout: string }> =>
160 new Promise((res, rej) => {
161 const env = { ...process.env, EXECUTOR_DATA_DIR: dataDir };
162 for (const k of ["EXECUTOR_API_KEY", "EXECUTOR_AUTH_TOKEN", "EXECUTOR_AUTH_PASSWORD"]) {
163 delete (env as Record<string, string | undefined>)[k];
164 }
165 const child = spawn("bun", ["run", CLI_ENTRY, ...args], { cwd: REPO_ROOT, env });
166 let stdout = "";
167 let approved = false;
168 child.stdout.on("data", (chunk: Buffer) => {
169 stdout += chunk.toString();
170 if (approved) return;
171 const match = stdout.match(/(https?:\/\/\S*user_code=\S+)/);
172 if (!match) return;
173 approved = true;
174 const url = new URL(match[1]);
175 url.searchParams.set("login_hint", approveEmail);
176 void fetch(url, { redirect: "manual" });
177 });
178 child.stderr.on("data", () => {});
179 child.on("error", rej);
180 child.on("close", (code) => res({ code, stdout }));
181 });
182
183scenario(
184 "CLI · two accounts on the same host get separate profiles",
185 { timeout: 120_000 },
186 Effect.gen(function* () {
187 const target = yield* Target;
188 if (target.name !== "cloud") return;
189
190 const runDir = yield* RunDir;
191 const dataDir = join(runDir, "multi-home");
192
193 // Two distinct hosted accounts (different user + org) on the SAME server.
194 const a = yield* target.newIdentity();
195 const b = yield* target.newIdentity();
196 const emailA = a.credentials?.email ?? a.label;
197 const emailB = b.credentials?.email ?? b.label;
198
199 // Log in as each with NO --name, so naming is driven by the account.
200 const loginA = yield* Effect.promise(() =>
201 runCliLogin(["login", "--base-url", CLOUD_BASE_URL, "--no-browser"], dataDir, emailA),
202 );
203 expect(loginA.code, "first login exited cleanly").toBe(0);
204 const loginB = yield* Effect.promise(() =>
205 runCliLogin(["login", "--base-url", CLOUD_BASE_URL, "--no-browser"], dataDir, emailB),
206 );
207 expect(loginB.code, "second login exited cleanly").toBe(0);
208
209 const store = JSON.parse(readFileSync(join(dataDir, "server-connections.json"), "utf8")) as {
210 defaultProfile: string | null;
211 profiles: Array<{
212 name: string;
213 connection: { origin: string; displayName?: string; auth?: { kind: string } };
214 }>;
215 };
216 const oauthProfiles = store.profiles.filter((p) => p.connection.auth?.kind === "oauth");
217 // The second login must NOT clobber the first, both accounts kept.
218 expect(oauthProfiles.length, "both accounts retained as separate profiles").toBe(2);
219 expect(new Set(oauthProfiles.map((p) => p.name)).size, "profile names are distinct").toBe(2);
220 const emails = new Set(oauthProfiles.map((p) => p.connection.displayName));
221 expect(emails.has(emailA) && emails.has(emailB), "both account emails present").toBe(true);
222 }),
223);
e2e/selfhost/cli-device-login.test.ts+135−0
@@ -0,0 +1,135 @@
1// Self-host: the CLI `executor login` device-authorization flow end to end
2// against the self-host app (Better Auth), terminal AND browser, both recorded.
3// The `executor` binary runs RFC 8628 in a real terminal (terminal.cast): it
4// discovers Better Auth's device endpoints via /api/auth/cli-login, prints the
5// verification URL, and polls. The browser leg is REAL (session.mp4): Playwright
6// opens the self-host /device page (signed in via the session cookie) and clicks
7// "Authorize device". The terminal then runs `whoami` and `tools sources`; a
8// clean exit of that chain proves the Better Auth device token is accepted as a
9// Bearer on the protected /api/* plane.
10import { readFileSync } from "node:fs";
11import { dirname, join, resolve } from "node:path";
12import { fileURLToPath } from "node:url";
13
14import { expect } from "@effect/vitest";
15import { Effect } from "effect";
16
17import { scenario } from "../src/scenario";
18import { Browser, Cli, RunDir, Target } from "../src/services";
19import { enterFocus } from "../src/timeline";
20import { SELFHOST_BASE_URL } from "../targets/selfhost";
21
22const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
23const CLI_ENTRY = join(REPO_ROOT, "apps", "cli", "src", "main.ts");
24
25scenario(
26 "CLI · executor login device flow → authenticated /api call",
27 { timeout: 180_000 },
28 Effect.scoped(
29 Effect.gen(function* () {
30 const target = yield* Target;
31 if (target.name !== "selfhost") return;
32
33 // Slow + hold the browser steps so the recording is watchable. Scoped to
34 // this scenario and restored after (files run serially, so a leaked flag
35 // would slow every later scenario).
36 const prevFilm = process.env.E2E_FILM;
37 yield* Effect.addFinalizer(() =>
38 Effect.sync(() => {
39 if (prevFilm === undefined) delete process.env.E2E_FILM;
40 else process.env.E2E_FILM = prevFilm;
41 }),
42 );
43 process.env.E2E_FILM = "1";
44
45 const cli = yield* Cli;
46 const browser = yield* Browser;
47 const runDir = yield* RunDir;
48 const dataDir = join(runDir, "cli-home");
49
50 // A signed-in identity (its session cookie authorizes the /device page).
51 const identity = yield* target.newIdentity();
52
53 const env = { ...process.env, EXECUTOR_DATA_DIR: dataDir };
54 for (const key of ["EXECUTOR_API_KEY", "EXECUTOR_AUTH_TOKEN", "EXECUTOR_AUTH_PASSWORD"]) {
55 delete (env as Record<string, string | undefined>)[key];
56 }
57
58 let resolveUrl!: (url: string) => void;
59 const verificationUrl = new Promise<string>((r) => {
60 resolveUrl = r;
61 });
62
63 const cli_ = `bun run ${CLI_ENTRY}`;
64 const journey =
65 `${cli_} login --base-url ${SELFHOST_BASE_URL} --no-browser --name selfhost && ` +
66 `${cli_} whoami --server selfhost && ` +
67 `${cli_} tools sources --server selfhost`;
68
69 const terminal = cli.session(
70 ["bash", "-c", journey],
71 async (session) => {
72 await session.screen.waitForText(/user_code=/, { timeoutMs: 60_000 });
73 const match = (await session.screen.text()).match(/(https?:\/\/\S*user_code=\S+)/);
74 if (!match) throw new Error("verification URL not found on screen");
75 resolveUrl(match[1]);
76 await session.screen.waitForText("Logged in to", { timeoutMs: 60_000 });
77 const exit = await session.waitForExit({ timeoutMs: 60_000 });
78 if (exit.reason !== "exited" || exit.exit.code !== 0) {
79 throw new Error(
80 `journey did not exit cleanly: ${JSON.stringify(exit)}\n${await session.screen.text()}`,
81 );
82 }
83 return session.screen.text();
84 },
85 {
86 cwd: REPO_ROOT,
87 env,
88 record: join(runDir, "terminal.cast"),
89 viewport: { cols: 300, rows: 48 },
90 },
91 );
92
93 // The browser leg, approve on the self-host /device page (session cookie
94 // from the identity authorizes it). Recorded to session.mp4.
95 const browserApproval = Effect.gen(function* () {
96 const url = yield* Effect.promise(() => verificationUrl);
97 yield* browser.session(identity, async ({ page, step }) => {
98 await step("Open the device verification page", async () => {
99 await page.goto(url, { waitUntil: "domcontentloaded" });
100 // The Authorize button appears once the page binds the signed-in user.
101 await page
102 .getByRole("button", { name: /Authorize device/i })
103 .waitFor({ timeout: 20_000 });
104 });
105 await step("Authorize the device", async () => {
106 await page.getByRole("button", { name: /Authorize device/i }).click();
107 await page.getByText(/Device approved/i).waitFor({ timeout: 15_000 });
108 });
109 });
110 // Cut the synced player back to the terminal for the "Logged in" + the
111 // authenticated /api call that follow the browser approval.
112 yield* Effect.promise(() => enterFocus(runDir, "terminal"));
113 });
114
115 // Reaching here means the whole `&&` chain exited 0, including the
116 // authenticated `tools sources` /api call.
117 yield* Effect.all([terminal, browserApproval], { concurrency: "unbounded" });
118
119 // The stored profile carries an oauth device-login credential, not a key.
120 const store = JSON.parse(readFileSync(join(dataDir, "server-connections.json"), "utf8")) as {
121 defaultProfile: string | null;
122 profiles: Array<{
123 name: string;
124 connection: { auth?: { kind: string; accessToken?: string } };
125 }>;
126 };
127 expect(store.defaultProfile, "the login became the default profile").toBe("selfhost");
128 const profile = store.profiles.find((p) => p.name === "selfhost");
129 expect(profile?.connection.auth?.kind, "credential is an oauth device token").toBe("oauth");
130 expect(typeof profile?.connection.auth?.accessToken, "an access token is stored").toBe(
131 "string",
132 );
133 }),
134 ),
135);
e2e/src/surfaces/cli.ts+10−3
@@ -3,11 +3,12 @@ · 2 unmodified lines
3// vitest; pass `record` to save an asciicast v2 the viewer can replay. The3// vitest; pass `record` to save an asciicast v2 the viewer can replay. The
4// recording is written in release so a timeout still leaves the evidence.4// recording is written in release so a timeout still leaves the evidence.
5import { writeFileSync } from "node:fs";5import { writeFileSync } from "node:fs";
6import { dirname } from "node:path";
6 7
7import { Effect } from "effect";8import { Effect } from "effect";
8import { TerminalControl, type Session } from "@kitlangton/terminal-control";9import { TerminalControl, type Session } from "@kitlangton/terminal-control";
9 10
10import { beat } from "../timeline";11import { beat, enterFocus, markRecordingStart } from "../timeline";
11 12
12export interface CliSurface {13export interface CliSurface {
13 readonly session: <T>(14 readonly session: <T>(
@@ -67,10 +68,16 @@ export const makeCliSurface = (): CliSurface => ({ · 53 unmodified lines
67 record: options?.record ? true : undefined,68 record: options?.record ? true : undefined,
68 viewport: options?.viewport,69 viewport: options?.viewport,
69 });70 });
70 return { tc, session };71 // Anchor the terminal recording on the run's focus timeline (symmetric
72 // with the browser surface), so a combined cli+browser scenario gets the
73 // synced SessionPlayer view (terminal/browser cuts + URL bar) for free.
74 const runDir = options?.record ? dirname(options.record) : null;
75 if (runDir) markRecordingStart(runDir, "terminal");
76 return { tc, session, runDir };
71 }),77 }),
72 ({ session }) =>78 ({ session, runDir }) =>
73 Effect.promise(async () => {79 Effect.promise(async () => {
80 if (runDir) await enterFocus(runDir, "terminal");
74 const result = await drive(session);81 const result = await drive(session);
75 // Hold the terminal's final frame (e.g. "connected") before the82 // Hold the terminal's final frame (e.g. "connected") before the
76 // recording stops, so it isn't a single flash in the film. Filming83 // recording stops, so it isn't a single flash in the film. Filming
packages/core/sdk/src/server-connection.ts+21−0
@@ -15,6 +15,18 @@ export type ExecutorServerAuth = · 14 unmodified lines
15 | {15 | {
16 readonly kind: "bearer";16 readonly kind: "bearer";
17 readonly token: string;17 readonly token: string;
18 }
19 | {
20 // OAuth 2.0 device-flow credential from `executor login` against a hosted
21 // server. The access token is sent as a Bearer; `refreshToken` +
22 // `expiresAt` (epoch seconds) + `tokenEndpoint` + `clientId` let the CLI
23 // refresh it before expiry without a fresh browser login.
24 readonly kind: "oauth";
25 readonly accessToken: string;
26 readonly refreshToken?: string;
27 readonly expiresAt?: number;
28 readonly tokenEndpoint?: string;
29 readonly clientId?: string;
18 };30 };
19 31
20export interface ExecutorServerConnection {32export interface ExecutorServerConnection {
@@ -125,6 +137,7 @@ export const getExecutorServerAuthorizationHeader = ( · 104 unmodified lines
125 const auth = connection.auth;137 const auth = connection.auth;
126 if (!auth) return null;138 if (!auth) return null;
127 if (auth.kind === "bearer") return `Bearer ${auth.token}`;139 if (auth.kind === "bearer") return `Bearer ${auth.token}`;
140 if (auth.kind === "oauth") return `Bearer ${auth.accessToken}`;
128 const encoded = encodeBasicCredentials(141 const encoded = encodeBasicCredentials(
129 `${auth.username ?? DEFAULT_EXECUTOR_SERVER_USERNAME}:${auth.password}`,142 `${auth.username ?? DEFAULT_EXECUTOR_SERVER_USERNAME}:${auth.password}`,
130 );143 );
@@ -141,6 +154,14 @@ const ExecutorServerAuthJson = Schema.Union([ · 10 unmodified lines
141 kind: Schema.Literal("bearer"),154 kind: Schema.Literal("bearer"),
142 token: Schema.String,155 token: Schema.String,
143 }),156 }),
157 Schema.Struct({
158 kind: Schema.Literal("oauth"),
159 accessToken: Schema.String,
160 refreshToken: Schema.optional(Schema.String),
161 expiresAt: Schema.optional(Schema.Number),
162 tokenEndpoint: Schema.optional(Schema.String),
163 clientId: Schema.optional(Schema.String),
164 }),
144]);165]);
145 166
146const ExecutorServerConnectionJson = Schema.Struct({167const ExecutorServerConnectionJson = Schema.Struct({