* 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).
Unified · Split
| @@ -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 | + | |
| 17 | +import { spawn } from "node:child_process"; | |
| 18 | + | |
| 19 | +export 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 | + | |
| 33 | +export 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 | + | |
| 42 | +export 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 | + | |
| 53 | +const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; | |
| 54 | +const DEFAULT_INTERVAL_SECONDS = 5; | |
| 55 | + | |
| 56 | +export class DeviceLoginError extends Error { | |
| 57 | + constructor(message: string) { | |
| 58 | + super(message); | |
| 59 | + this.name = "DeviceLoginError"; | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +const cliLoginUrl = (origin: string): string => `${origin.replace(/\/+$/, "")}/api/auth/cli-login`; | |
| 64 | + | |
| 65 | +const asString = (value: unknown): string | undefined => | |
| 66 | + typeof value === "string" && value.length > 0 ? value : undefined; | |
| 67 | + | |
| 68 | +const 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). */ | |
| 72 | +const 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 | + */ | |
| 85 | +export 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. */ | |
| 102 | +export const accessTokenExpiry = (accessToken: string): number | undefined => | |
| 103 | + asNumber(decodeAccessTokenClaims(accessToken)?.exp); | |
| 104 | + | |
| 105 | +const 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 | + | |
| 115 | +const 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 | + | |
| 123 | +const 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 | + | |
| 129 | +const 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 | + | |
| 143 | +const 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 | + | |
| 153 | +export 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 | + | |
| 185 | +export 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 | + | |
| 213 | +const 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 | + */ | |
| 220 | +export 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. */ | |
| 285 | +export 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. */ | |
| 315 | +export 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 | +}; | |
| @@ -69,11 +69,20 @@ import { ExecutorApi } from "@executor-js/api"; · 68 unmodified lines | ||
| 69 | 69 | import { |
| 70 | 70 | getExecutorServerAuthorizationHeader, |
| 71 | 71 | normalizeExecutorServerConnection, |
| 72 | + normalizeExecutorServerOrigin, | |
| 72 | 73 | type ExecutorLocalServerKind, |
| 73 | 74 | type ExecutorLocalServerManifest, |
| 74 | 75 | type ExecutorServerConnection, |
| 75 | 76 | type ExecutorServerConnectionInput, |
| 76 | 77 | } from "@executor-js/sdk/shared"; |
| 78 | +import { | |
| 79 | + decodeAccessTokenClaims, | |
| 80 | + discoverCliLogin, | |
| 81 | + openBrowser, | |
| 82 | + pollForDeviceTokens, | |
| 83 | + refreshDeviceTokens, | |
| 84 | + requestDeviceCode, | |
| 85 | +} from "./device-login"; | |
| 77 | 86 | import { |
| 78 | 87 | startServer, |
| 79 | 88 | runMcpStdioServer, |
| @@ -138,6 +147,7 @@ import { · 58 unmodified lines | ||
| 138 | 147 | setDefaultCliServerConnectionProfile, |
| 139 | 148 | upsertCliServerConnectionProfile, |
| 140 | 149 | validateCliServerConnectionProfileName, |
| 150 | + type CliServerConnectionStore, | |
| 141 | 151 | } from "./server-profile"; |
| 142 | 152 | import { |
| 143 | 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. | |
| 603 | +const OAUTH_REFRESH_SKEW_SECONDS = 60; | |
| 604 | + | |
| 605 | +const profileNameFromKey = (key: string): string | null => | |
| 606 | + key.startsWith("profile:") ? key.slice("profile:".length) : null; | |
| 607 | + | |
| 608 | +const 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 | + | |
| 590 | 653 | const resolveExecutorServerConnection = ( |
| 591 | 654 | target: ServerTarget, |
| 592 | 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 | 679 | if (decision.kind === "use-active") return requested; |
| 617 | 680 | |
| 618 | 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 | 688 | if (yield* isServerReachable(requested.origin)) { |
| 620 | 689 | return requested; |
| 621 | 690 | } |
| @@ -1995,6 +2064,254 @@ const serverCommand = Command.make("server").pipe( · 1373 unmodified lines | ||
| 1995 | 2064 | Command.withDescription("Manage named Executor server profiles"), |
| 1996 | 2065 | ); |
| 1997 | 2066 | |
| 2067 | +const 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 | + | |
| 2077 | +const 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). | |
| 2088 | +const 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. | |
| 2101 | +const 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. | |
| 2127 | +const 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 | + | |
| 2161 | +const loginNameOption = Options.string("name").pipe( | |
| 2162 | + Options.optional, | |
| 2163 | + Options.withDescription("Profile name to save the login under (defaults to your account)."), | |
| 2164 | +); | |
| 2165 | + | |
| 2166 | +const noBrowserOption = Options.boolean("no-browser").pipe( | |
| 2167 | + Options.withDefault(false), | |
| 2168 | + Options.withDescription("Print the verification URL instead of opening a browser."), | |
| 2169 | +); | |
| 2170 | + | |
| 2171 | +const 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 | + | |
| 2244 | +const 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 | + | |
| 2277 | +const 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 | + | |
| 1998 | 2315 | const webCommand = Command.make( |
| 1999 | 2316 | "web", |
| 2000 | 2317 | { |
| @@ -2562,6 +2879,9 @@ const root = Command.make("executor").pipe( · 561 unmodified lines | ||
| 2562 | 2879 | resumeCommand, |
| 2563 | 2880 | toolsCommand, |
| 2564 | 2881 | installCommand, |
| 2882 | + loginCommand, | |
| 2883 | + logoutCommand, | |
| 2884 | + whoamiCommand, | |
| 2565 | 2885 | serverCommand, |
| 2566 | 2886 | webCommand, |
| 2567 | 2887 | daemonCommand, |
| @@ -43,8 +43,10 @@ export const withCliServerAuthFallback = ( · 42 unmodified lines | ||
| 43 | 43 | |
| 44 | 44 | export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => { |
| 45 | 45 | if (connection.kind !== "http") return false; |
| 46 | − // Explicit auth means the user is pointing at an existing server (e.g. a | |
| 47 | − // desktop sidecar or a remote), not asking us to spin up a local daemon. | |
| 46 | + // A stored credential (basic password, bearer key, or an oauth device-login | |
| 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 | 50 | if (connection.auth) return false; |
| 49 | 51 | const url = new URL(connection.origin); |
| 50 | 52 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); |
| @@ -52,6 +52,14 @@ const PersistedAuth = Schema.Union([ · 51 unmodified lines | ||
| 52 | 52 | kind: Schema.Literal("bearer"), |
| 53 | 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 | |
| 57 | 65 | const PersistedConnection = Schema.Struct({ |
| @@ -0,0 +1,169 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect, Layer } from "effect"; | |
| 3 | +import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose"; | |
| 4 | + | |
| 5 | +import { ApiKeyService } from "../auth/api-keys"; | |
| 6 | +import { UserStoreService } from "../auth/context"; | |
| 7 | +import type { JwtBearerConfig } from "../auth/workos-auth-provider"; | |
| 8 | +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | |
| 9 | +import { resolveProtectedPrincipal } from "./protected"; | |
| 10 | + | |
| 11 | +const createdAt = new Date("2026-01-01T00:00:00.000Z"); | |
| 12 | +const issuer = "https://test-authkit.example.com"; | |
| 13 | +const 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`. | |
| 18 | +const 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. | |
| 36 | +const 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 | + | |
| 43 | +const 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 | + | |
| 61 | +const 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 | + | |
| 88 | +const run = (request: Request, jwt: JwtBearerConfig) => | |
| 89 | + resolveProtectedPrincipal(request, jwt).pipe( | |
| 90 | + Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)), | |
| 91 | + ); | |
| 92 | + | |
| 93 | +const request = (token: string) => | |
| 94 | + new Request("https://executor.test/api/tools", { | |
| 95 | + headers: { authorization: `Bearer ${token}` }, | |
| 96 | + }); | |
| 97 | + | |
| 98 | +describe("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 | +}); | |
| @@ -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 | + | |
| 20 | +import { env } from "cloudflare:workers"; | |
| 21 | + | |
| 22 | +import { createCachedRemoteJWKSet } from "./jwks-cache"; | |
| 23 | +import type { JwtBearerConfig } from "./workos-auth-provider"; | |
| 24 | + | |
| 25 | +const 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. | |
| 31 | +export const workosApiJwtBearerConfig: JwtBearerConfig = { | |
| 32 | + jwks: createCachedRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`)), | |
| 33 | + issuer: AUTHKIT_DOMAIN, | |
| 34 | + audience: env.WORKOS_CLIENT_ID, | |
| 35 | +}; | |
| @@ -44,6 +44,17 @@ const CreateOrganizationResponse = Schema.Struct({ · 43 unmodified lines | ||
| 44 | 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. | |
| 51 | +const CliLoginResponse = Schema.Struct({ | |
| 52 | + provider: Schema.Literal("workos"), | |
| 53 | + deviceAuthorizationEndpoint: Schema.String, | |
| 54 | + tokenEndpoint: Schema.String, | |
| 55 | + clientId: Schema.String, | |
| 56 | +}); | |
| 57 | + | |
| 47 | 58 | // `state` is optional — some WorkOS-initiated redirects arrive at the |
| 48 | 59 | // callback without the state we set on /auth/login. The CSRF check is |
| 49 | 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 | 167 | query: AuthCallbackSearch, |
| 157 | 168 | error: AuthErrors, |
| 158 | 169 | }), |
| 159 | − ) {} | |
| 170 | + ) | |
| 171 | + .add(HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { success: CliLoginResponse })) {} | |
| 160 | 172 | |
| 161 | 173 | /** Session auth endpoints — require a logged-in user, may not have an org */ |
| 162 | 174 | export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") |
| @@ -236,6 +236,22 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 235 unmodified lines | ||
| 236 | 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 | |
| @@ -29,6 +29,7 @@ · 28 unmodified lines | ||
| 29 | 29 | |
| 30 | 30 | import { Effect, Layer } from "effect"; |
| 31 | 31 | import { HttpServerResponse } from "effect/unstable/http"; |
| 32 | +import type { JWTVerifyGetKey } from "jose"; | |
| 32 | 33 | |
| 33 | 34 | import { |
| 34 | 35 | IdentityProvider, |
| @@ -39,6 +40,7 @@ import { · 4 unmodified lines | ||
| 39 | 40 | import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server"; |
| 40 | 41 | |
| 41 | 42 | import { ApiKeyService } from "./api-keys"; |
| 43 | +import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; | |
| 42 | 44 | import { BEARER_PREFIX } from "./bearer"; |
| 43 | 45 | import { |
| 44 | 46 | authorizeOrganization, |
| @@ -49,6 +51,20 @@ import { UserStoreService } from "./context"; · 4 unmodified lines | ||
| 49 | 51 | import { sealedSessionDisplayName } from "./middleware"; |
| 50 | 52 | import type { UserStoreError, WorkOSError } from "./errors"; |
| 51 | 53 | import { WorkOSClient } from "./workos"; |
| 54 | +import { 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 | + */ | |
| 63 | +export interface JwtBearerConfig { | |
| 64 | + readonly jwks: JWTVerifyGetKey; | |
| 65 | + readonly issuer: string; | |
| 66 | + readonly audience: string; | |
| 67 | +} | |
| 52 | 68 | |
| 53 | 69 | // The exact machine codes + messages each rejected path has always emitted. |
| 54 | 70 | // 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 | 86 | code: "no_organization", |
| 71 | 87 | message: "No organization in session", |
| 72 | 88 | }; |
| 89 | +const INVALID_ACCESS_TOKEN = { | |
| 90 | + code: "invalid_access_token", | |
| 91 | + message: "Invalid or expired access token", | |
| 92 | +}; | |
| 93 | +const ACCESS_TOKEN_VERIFICATION_UNAVAILABLE = { | |
| 94 | + code: "access_token_verification_unavailable", | |
| 95 | + message: "Access token verification is temporarily unavailable", | |
| 96 | +}; | |
| 97 | +const 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`). | |
| 105 | +const looksLikeJwt = (token: string): boolean => token.split(".").length === 3; | |
| 73 | 106 | |
| 74 | −export 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 | + */ | |
| 116 | +const 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 | + */ | |
| 157 | +export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) => | |
| 75 | 158 | Effect.gen(function* () { |
| 76 | 159 | const authHeader = request.headers.get("authorization"); |
| 77 | 160 | if (!authHeader) return null; |
| @@ -83,6 +166,8 @@ export const resolveApiKeyPrincipal = (request: Request) => · 5 unmodified lines | ||
| 83 | 166 | const value = authHeader.slice(BEARER_PREFIX.length).trim(); |
| 84 | 167 | if (!value) return yield* new Unauthorized(INVALID_API_KEY); |
| 85 | 168 | |
| 169 | + if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt); | |
| 170 | + | |
| 86 | 171 | const apiKeys = yield* ApiKeyService; |
| 87 | 172 | const principal = yield* apiKeys |
| 88 | 173 | .validate(value) |
| @@ -149,14 +234,15 @@ export const resolveSessionPrincipal = (request: Request) => · 60 unmodified lines | ||
| 149 | 234 | */ |
| 150 | 235 | export const resolveProtectedPrincipal = ( |
| 151 | 236 | request: Request, |
| 237 | + jwt: JwtBearerConfig | null = null, | |
| 152 | 238 | ): Effect.Effect< |
| 153 | 239 | Principal, |
| 154 | 240 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, |
| 155 | 241 | WorkOSClient | ApiKeyService | UserStoreService |
| 156 | 242 | > => |
| 157 | 243 | Effect.gen(function* () { |
| 158 | − const apiKeyPrincipal = yield* resolveApiKeyPrincipal(request); | |
| 159 | − if (apiKeyPrincipal) return apiKeyPrincipal; | |
| 244 | + const bearerPrincipal = yield* resolveApiKeyPrincipal(request, jwt); | |
| 245 | + if (bearerPrincipal) return bearerPrincipal; | |
| 160 | 246 | return yield* resolveSessionPrincipal(request); |
| 161 | 247 | }); |
| 162 | 248 | |
| @@ -180,7 +266,7 @@ export const workosIdentityLayer: Layer.Layer< · 17 unmodified lines | ||
| 180 | 266 | const context = yield* Effect.context<WorkOSClient | ApiKeyService | UserStoreService>(); |
| 181 | 267 | return IdentityProvider.of({ |
| 182 | 268 | authenticate: (request) => |
| 183 | − resolveProtectedPrincipal(request).pipe( | |
| 269 | + resolveProtectedPrincipal(request, workosApiJwtBearerConfig).pipe( | |
| 184 | 270 | // `UserStoreError` / `WorkOSError` are org-resolution infra failures — |
| 185 | 271 | // surface as a 500 defect, exactly as the old inline resolver let them |
| 186 | 272 | // bubble. The narrow `die` here is the runtime edge for that infra |
| @@ -73,6 +73,24 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 72 unmodified lines | ||
| 73 | 73 | // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). |
| 74 | 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 | 94 | const { appLayer, toWebHandler } = ExecutorApp.make({ |
| 77 | 95 | plugins: selfHostPlugins, |
| 78 | 96 | providers: { |
| @@ -86,7 +104,11 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 7 unmodified lines | ||
| 86 | 104 | }, |
| 87 | 105 | extensions: { |
| 88 | 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 | 112 | HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)), |
| 91 | 113 | // Browser approval of paused MCP executions: the console resume page |
| 92 | 114 | // reads paused detail (GET) and records the decision (POST .../resume), |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { betterAuth, type BetterAuthOptions } from "better-auth"; |
| 2 | 2 | import { APIError } from "better-auth/api"; |
| 3 | −import { admin, bearer, mcp, organization } from "better-auth/plugins"; | |
| 3 | +import { admin, bearer, deviceAuthorization, mcp, organization } from "better-auth/plugins"; | |
| 4 | 4 | import { apiKey } from "@better-auth/api-key"; |
| 5 | 5 | import { type Client } from "@libsql/client"; |
| 6 | 6 | import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql"; |
| @@ -114,6 +114,14 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 107 unmodified lines | ||
| 114 | 114 | admin(), |
| 115 | 115 | apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), |
| 116 | 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 | 125 | // `consentPage` makes the MCP authorize flow redirect to a human approval |
| 118 | 126 | // screen instead of auto-issuing a code — but ONLY when the request |
| 119 | 127 | // carries `prompt=consent`. MCP clients don't send that, so the self-host |
| @@ -0,0 +1,137 @@ | ||
| 1 | +import { useEffect, useState } from "react"; | |
| 2 | + | |
| 3 | +import { 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 | + | |
| 19 | +type Phase = "binding" | "ready" | "approved" | "denied" | "error"; | |
| 20 | + | |
| 21 | +export 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 | +}; | |
| @@ -11,6 +11,7 @@ import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shel · 10 unmodified lines | ||
| 11 | 11 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; |
| 12 | 12 | |
| 13 | 13 | import { authClient } from "../auth-client"; |
| 14 | +import { DevicePage } from "../device"; | |
| 14 | 15 | import { McpConsentPage } from "../mcp-consent"; |
| 15 | 16 | import { LoginPage } from "../login"; |
| 16 | 17 | import { 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 | 145 | return ( |
| 131 | 146 | <AuthProvider> |
| 132 | 147 | <AuthGate> |
| @@ -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. | |
| 11 | +import { spawn } from "node:child_process"; | |
| 12 | +import { readFileSync } from "node:fs"; | |
| 13 | +import { dirname, join, resolve } from "node:path"; | |
| 14 | +import { fileURLToPath } from "node:url"; | |
| 15 | + | |
| 16 | +import { expect } from "@effect/vitest"; | |
| 17 | +import { Effect } from "effect"; | |
| 18 | + | |
| 19 | +import { scenario } from "../src/scenario"; | |
| 20 | +import { Browser, Cli, RunDir, Target } from "../src/services"; | |
| 21 | +import { enterFocus } from "../src/timeline"; | |
| 22 | +import { CLOUD_BASE_URL } from "../targets/cloud"; | |
| 23 | + | |
| 24 | +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); | |
| 25 | +const CLI_ENTRY = join(REPO_ROOT, "apps", "cli", "src", "main.ts"); | |
| 26 | + | |
| 27 | +scenario( | |
| 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). | |
| 155 | +const 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 | + | |
| 183 | +scenario( | |
| 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 | +); | |
| @@ -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. | |
| 10 | +import { readFileSync } from "node:fs"; | |
| 11 | +import { dirname, join, resolve } from "node:path"; | |
| 12 | +import { fileURLToPath } from "node:url"; | |
| 13 | + | |
| 14 | +import { expect } from "@effect/vitest"; | |
| 15 | +import { Effect } from "effect"; | |
| 16 | + | |
| 17 | +import { scenario } from "../src/scenario"; | |
| 18 | +import { Browser, Cli, RunDir, Target } from "../src/services"; | |
| 19 | +import { enterFocus } from "../src/timeline"; | |
| 20 | +import { SELFHOST_BASE_URL } from "../targets/selfhost"; | |
| 21 | + | |
| 22 | +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); | |
| 23 | +const CLI_ENTRY = join(REPO_ROOT, "apps", "cli", "src", "main.ts"); | |
| 24 | + | |
| 25 | +scenario( | |
| 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 | +); | |
| @@ -3,11 +3,12 @@ · 2 unmodified lines | ||
| 3 | 3 | // vitest; pass `record` to save an asciicast v2 the viewer can replay. The |
| 4 | 4 | // recording is written in release so a timeout still leaves the evidence. |
| 5 | 5 | import { writeFileSync } from "node:fs"; |
| 6 | +import { dirname } from "node:path"; | |
| 6 | 7 | |
| 7 | 8 | import { Effect } from "effect"; |
| 8 | 9 | import { TerminalControl, type Session } from "@kitlangton/terminal-control"; |
| 9 | 10 | |
| 10 | −import { beat } from "../timeline"; | |
| 11 | +import { beat, enterFocus, markRecordingStart } from "../timeline"; | |
| 11 | 12 | |
| 12 | 13 | export interface CliSurface { |
| 13 | 14 | readonly session: <T>( |
| @@ -67,10 +68,16 @@ export const makeCliSurface = (): CliSurface => ({ · 53 unmodified lines | ||
| 67 | 68 | record: options?.record ? true : undefined, |
| 68 | 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 | 79 | Effect.promise(async () => { |
| 80 | + if (runDir) await enterFocus(runDir, "terminal"); | |
| 74 | 81 | const result = await drive(session); |
| 75 | 82 | // Hold the terminal's final frame (e.g. "connected") before the |
| 76 | 83 | // recording stops, so it isn't a single flash in the film. Filming |
| @@ -15,6 +15,18 @@ export type ExecutorServerAuth = · 14 unmodified lines | ||
| 15 | 15 | | { |
| 16 | 16 | readonly kind: "bearer"; |
| 17 | 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 | |
| 20 | 32 | export interface ExecutorServerConnection { |
| @@ -125,6 +137,7 @@ export const getExecutorServerAuthorizationHeader = ( · 104 unmodified lines | ||
| 125 | 137 | const auth = connection.auth; |
| 126 | 138 | if (!auth) return null; |
| 127 | 139 | if (auth.kind === "bearer") return `Bearer ${auth.token}`; |
| 140 | + if (auth.kind === "oauth") return `Bearer ${auth.accessToken}`; | |
| 128 | 141 | const encoded = encodeBasicCredentials( |
| 129 | 142 | `${auth.username ?? DEFAULT_EXECUTOR_SERVER_USERNAME}:${auth.password}`, |
| 130 | 143 | ); |
| @@ -141,6 +154,14 @@ const ExecutorServerAuthJson = Schema.Union([ · 10 unmodified lines | ||
| 141 | 154 | kind: Schema.Literal("bearer"), |
| 142 | 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 | |
| 146 | 167 | const ExecutorServerConnectionJson = Schema.Struct({ |