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

Showing the changes to apps/cli/src (4 of 17 files). Show all

Unified · Split

4 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
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({