* 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 | import { | 69 | import { |
| 70 | getExecutorServerAuthorizationHeader, | 70 | getExecutorServerAuthorizationHeader, |
| 71 | normalizeExecutorServerConnection, | 71 | normalizeExecutorServerConnection, |
| 72 | normalizeExecutorServerOrigin, | ||
| 72 | type ExecutorLocalServerKind, | 73 | type ExecutorLocalServerKind, |
| 73 | type ExecutorLocalServerManifest, | 74 | type ExecutorLocalServerManifest, |
| 74 | type ExecutorServerConnection, | 75 | type ExecutorServerConnection, |
| 75 | type ExecutorServerConnectionInput, | 76 | type ExecutorServerConnectionInput, |
| 76 | } from "@executor-js/sdk/shared"; | 77 | } from "@executor-js/sdk/shared"; |
| 78 | import { | ||
| 79 | decodeAccessTokenClaims, | ||
| 80 | discoverCliLogin, | ||
| 81 | openBrowser, | ||
| 82 | pollForDeviceTokens, | ||
| 83 | refreshDeviceTokens, | ||
| 84 | requestDeviceCode, | ||
| 85 | } from "./device-login"; | ||
| 77 | import { | 86 | import { |
| 78 | startServer, | 87 | startServer, |
| 79 | runMcpStdioServer, | 88 | runMcpStdioServer, |
| @@ -138,6 +147,7 @@ import { · 58 unmodified lines | |||
| 138 | setDefaultCliServerConnectionProfile, | 147 | setDefaultCliServerConnectionProfile, |
| 139 | upsertCliServerConnectionProfile, | 148 | upsertCliServerConnectionProfile, |
| 140 | validateCliServerConnectionProfileName, | 149 | validateCliServerConnectionProfileName, |
| 150 | type CliServerConnectionStore, | ||
| 141 | } from "./server-profile"; | 151 | } from "./server-profile"; |
| 142 | import { | 152 | import { |
| 143 | buildResumeContentTemplate, | 153 | buildResumeContentTemplate, |
| @@ -587,6 +597,59 @@ const resolveRequestedExecutorServerConnection = ( · 443 unmodified lines | |||
| 587 | }; | 597 | }; |
| 588 | }); | 598 | }); |
| 589 | 599 | ||
| 600 | // Refresh an `oauth` (device-login) credential a minute before it expires, so | ||
| 601 | // `executor call` against a hosted server keeps working long after the browser | ||
| 602 | // login. The refreshed tokens are written back to the originating profile. | ||
| 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 | const resolveExecutorServerConnection = ( | 653 | const resolveExecutorServerConnection = ( |
| 591 | target: ServerTarget, | 654 | target: ServerTarget, |
| 592 | ): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> => | 655 | ): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| @@ -612,10 +675,16 @@ const resolveExecutorServerConnection = ( · 19 unmodified lines | |||
| 612 | ); | 675 | ); |
| 613 | } | 676 | } |
| 614 | 677 | ||
| 615 | const requested = decision.connection; | 678 | const requested = yield* refreshOAuthConnection(decision.connection); |
| 616 | if (decision.kind === "use-active") return requested; | 679 | if (decision.kind === "use-active") return requested; |
| 617 | 680 | ||
| 618 | if (!canAutoStartCliServerConnection(requested)) { | 681 | if (!canAutoStartCliServerConnection(requested)) { |
| 682 | // An authenticated remote connection (oauth device-login, bearer key, or | ||
| 683 | // basic password): use it directly. The /api/health liveness probe is only | ||
| 684 | // a gate for the local auto-start decision and isn't necessarily exposed | ||
| 685 | // by hosted servers, the real API call surfaces any connectivity/auth | ||
| 686 | // error with proper context. | ||
| 687 | if (requested.auth) return requested; | ||
| 619 | if (yield* isServerReachable(requested.origin)) { | 688 | if (yield* isServerReachable(requested.origin)) { |
| 620 | return requested; | 689 | return requested; |
| 621 | } | 690 | } |
| @@ -1995,6 +2064,254 @@ const serverCommand = Command.make("server").pipe( · 1373 unmodified lines | |||
| 1995 | Command.withDescription("Manage named Executor server profiles"), | 2064 | Command.withDescription("Manage named Executor server profiles"), |
| 1996 | ); | 2065 | ); |
| 1997 | 2066 | ||
| 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 | const webCommand = Command.make( | 2315 | const webCommand = Command.make( |
| 1999 | "web", | 2316 | "web", |
| 2000 | { | 2317 | { |
| @@ -2562,6 +2879,9 @@ const root = Command.make("executor").pipe( · 561 unmodified lines | |||
| 2562 | resumeCommand, | 2879 | resumeCommand, |
| 2563 | toolsCommand, | 2880 | toolsCommand, |
| 2564 | installCommand, | 2881 | installCommand, |
| 2882 | loginCommand, | ||
| 2883 | logoutCommand, | ||
| 2884 | whoamiCommand, | ||
| 2565 | serverCommand, | 2885 | serverCommand, |
| 2566 | webCommand, | 2886 | webCommand, |
| 2567 | daemonCommand, | 2887 | daemonCommand, |
| @@ -43,8 +43,10 @@ export const withCliServerAuthFallback = ( · 42 unmodified lines | |||
| 43 | 43 | ||
| 44 | export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => { | 44 | export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => { |
| 45 | if (connection.kind !== "http") return false; | 45 | if (connection.kind !== "http") return false; |
| 46 | // Explicit auth means the user is pointing at an existing server (e.g. a | 46 | // A stored credential (basic password, bearer key, or an oauth device-login |
| 47 | // desktop sidecar or a remote), not asking us to spin up a local daemon. | 47 | // token) means the user is targeting an EXISTING authenticated server, never |
| 48 | // spawn a local daemon for it, even when it happens to be on http://localhost | ||
| 49 | // (e.g. a hosted server reached over a tunnel, or a local e2e cloud server). | ||
| 48 | if (connection.auth) return false; | 50 | if (connection.auth) return false; |
| 49 | const url = new URL(connection.origin); | 51 | const url = new URL(connection.origin); |
| 50 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); | 52 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); |
| @@ -52,6 +52,14 @@ const PersistedAuth = Schema.Union([ · 51 unmodified lines | |||
| 52 | kind: Schema.Literal("bearer"), | 52 | kind: Schema.Literal("bearer"), |
| 53 | token: Schema.String, | 53 | token: Schema.String, |
| 54 | }), | 54 | }), |
| 55 | Schema.Struct({ | ||
| 56 | kind: Schema.Literal("oauth"), | ||
| 57 | accessToken: Schema.String, | ||
| 58 | refreshToken: Schema.optional(Schema.String), | ||
| 59 | expiresAt: Schema.optional(Schema.Number), | ||
| 60 | tokenEndpoint: Schema.optional(Schema.String), | ||
| 61 | clientId: Schema.optional(Schema.String), | ||
| 62 | }), | ||
| 55 | ]); | 63 | ]); |
| 56 | 64 | ||
| 57 | const PersistedConnection = Schema.Struct({ | 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 | slug: Schema.String, | 44 | slug: Schema.String, |
| 45 | }); | 45 | }); |
| 46 | 46 | ||
| 47 | // CLI device-login discovery (`executor login`). Tells the CLI where to run | ||
| 48 | // the OAuth 2.0 Device Authorization Grant (RFC 8628) and which public client | ||
| 49 | // to use. The CLI hits these provider endpoints directly, gets a WorkOS access | ||
| 50 | // token (a JWT), and sends it as a Bearer to the `/api/*` plane. | ||
| 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 | // `state` is optional — some WorkOS-initiated redirects arrive at the | 58 | // `state` is optional — some WorkOS-initiated redirects arrive at the |
| 48 | // callback without the state we set on /auth/login. The CSRF check is | 59 | // callback without the state we set on /auth/login. The CSRF check is |
| 49 | // only enforced when state is present (see callback handler). | 60 | // only enforced when state is present (see callback handler). |
| @@ -156,7 +167,8 @@ export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") · 106 unmodified lines | |||
| 156 | query: AuthCallbackSearch, | 167 | query: AuthCallbackSearch, |
| 157 | error: AuthErrors, | 168 | error: AuthErrors, |
| 158 | }), | 169 | }), |
| 159 | ) {} | 170 | ) |
| 171 | .add(HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { success: CliLoginResponse })) {} | ||
| 160 | 172 | ||
| 161 | /** Session auth endpoints — require a logged-in user, may not have an org */ | 173 | /** Session auth endpoints — require a logged-in user, may not have an org */ |
| 162 | export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") | 174 | export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") |
| @@ -236,6 +236,22 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 235 unmodified lines | |||
| 236 | STATE_COOKIE, | 236 | STATE_COOKIE, |
| 237 | ); | 237 | ); |
| 238 | }), | 238 | }), |
| 239 | ) | ||
| 240 | // CLI device-login discovery. The WorkOS device endpoints live on the | ||
| 241 | // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, | ||
| 242 | // the SAME base the SDK uses, so e2e points the CLI at the emulator with | ||
| 243 | // zero extra wiring). The CLI runs RFC 8628 against them as a public | ||
| 244 | // client (no secret) and gets a WorkOS access-token JWT back. | ||
| 245 | .handle("cliLogin", () => | ||
| 246 | Effect.sync(() => { | ||
| 247 | const base = (env.WORKOS_API_URL ?? "https://api.workos.com").replace(/\/+$/, ""); | ||
| 248 | return { | ||
| 249 | provider: "workos" as const, | ||
| 250 | deviceAuthorizationEndpoint: `${base}/user_management/authorize/device`, | ||
| 251 | tokenEndpoint: `${base}/user_management/authenticate`, | ||
| 252 | clientId: env.WORKOS_CLIENT_ID, | ||
| 253 | }; | ||
| 254 | }), | ||
| 239 | ), | 255 | ), |
| 240 | ); | 256 | ); |
| 241 | 257 | ||
| @@ -29,6 +29,7 @@ · 28 unmodified lines | |||
| 29 | 29 | ||
| 30 | import { Effect, Layer } from "effect"; | 30 | import { Effect, Layer } from "effect"; |
| 31 | import { HttpServerResponse } from "effect/unstable/http"; | 31 | import { HttpServerResponse } from "effect/unstable/http"; |
| 32 | import type { JWTVerifyGetKey } from "jose"; | ||
| 32 | 33 | ||
| 33 | import { | 34 | import { |
| 34 | IdentityProvider, | 35 | IdentityProvider, |
| @@ -39,6 +40,7 @@ import { · 4 unmodified lines | |||
| 39 | import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server"; | 40 | import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server"; |
| 40 | 41 | ||
| 41 | import { ApiKeyService } from "./api-keys"; | 42 | import { ApiKeyService } from "./api-keys"; |
| 43 | import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; | ||
| 42 | import { BEARER_PREFIX } from "./bearer"; | 44 | import { BEARER_PREFIX } from "./bearer"; |
| 43 | import { | 45 | import { |
| 44 | authorizeOrganization, | 46 | authorizeOrganization, |
| @@ -49,6 +51,20 @@ import { UserStoreService } from "./context"; · 4 unmodified lines | |||
| 49 | import { sealedSessionDisplayName } from "./middleware"; | 51 | import { sealedSessionDisplayName } from "./middleware"; |
| 50 | import type { UserStoreError, WorkOSError } from "./errors"; | 52 | import type { UserStoreError, WorkOSError } from "./errors"; |
| 51 | import { WorkOSClient } from "./workos"; | 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 | // The exact machine codes + messages each rejected path has always emitted. | 69 | // The exact machine codes + messages each rejected path has always emitted. |
| 54 | // Carried on the shared identity error so the failure strategy renders the | 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 | code: "no_organization", | 86 | code: "no_organization", |
| 71 | message: "No organization in session", | 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 | Effect.gen(function* () { | 158 | Effect.gen(function* () { |
| 76 | const authHeader = request.headers.get("authorization"); | 159 | const authHeader = request.headers.get("authorization"); |
| 77 | if (!authHeader) return null; | 160 | if (!authHeader) return null; |
| @@ -83,6 +166,8 @@ export const resolveApiKeyPrincipal = (request: Request) => · 5 unmodified lines | |||
| 83 | const value = authHeader.slice(BEARER_PREFIX.length).trim(); | 166 | const value = authHeader.slice(BEARER_PREFIX.length).trim(); |
| 84 | if (!value) return yield* new Unauthorized(INVALID_API_KEY); | 167 | if (!value) return yield* new Unauthorized(INVALID_API_KEY); |
| 85 | 168 | ||
| 169 | if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt); | ||
| 170 | |||
| 86 | const apiKeys = yield* ApiKeyService; | 171 | const apiKeys = yield* ApiKeyService; |
| 87 | const principal = yield* apiKeys | 172 | const principal = yield* apiKeys |
| 88 | .validate(value) | 173 | .validate(value) |
| @@ -149,14 +234,15 @@ export const resolveSessionPrincipal = (request: Request) => · 60 unmodified lines | |||
| 149 | */ | 234 | */ |
| 150 | export const resolveProtectedPrincipal = ( | 235 | export const resolveProtectedPrincipal = ( |
| 151 | request: Request, | 236 | request: Request, |
| 237 | jwt: JwtBearerConfig | null = null, | ||
| 152 | ): Effect.Effect< | 238 | ): Effect.Effect< |
| 153 | Principal, | 239 | Principal, |
| 154 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, | 240 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, |
| 155 | WorkOSClient | ApiKeyService | UserStoreService | 241 | WorkOSClient | ApiKeyService | UserStoreService |
| 156 | > => | 242 | > => |
| 157 | Effect.gen(function* () { | 243 | Effect.gen(function* () { |
| 158 | const apiKeyPrincipal = yield* resolveApiKeyPrincipal(request); | 244 | const bearerPrincipal = yield* resolveApiKeyPrincipal(request, jwt); |
| 159 | if (apiKeyPrincipal) return apiKeyPrincipal; | 245 | if (bearerPrincipal) return bearerPrincipal; |
| 160 | return yield* resolveSessionPrincipal(request); | 246 | return yield* resolveSessionPrincipal(request); |
| 161 | }); | 247 | }); |
| 162 | 248 | ||
| @@ -180,7 +266,7 @@ export const workosIdentityLayer: Layer.Layer< · 17 unmodified lines | |||
| 180 | const context = yield* Effect.context<WorkOSClient | ApiKeyService | UserStoreService>(); | 266 | const context = yield* Effect.context<WorkOSClient | ApiKeyService | UserStoreService>(); |
| 181 | return IdentityProvider.of({ | 267 | return IdentityProvider.of({ |
| 182 | authenticate: (request) => | 268 | authenticate: (request) => |
| 183 | resolveProtectedPrincipal(request).pipe( | 269 | resolveProtectedPrincipal(request, workosApiJwtBearerConfig).pipe( |
| 184 | // `UserStoreError` / `WorkOSError` are org-resolution infra failures — | 270 | // `UserStoreError` / `WorkOSError` are org-resolution infra failures — |
| 185 | // surface as a 500 defect, exactly as the old inline resolver let them | 271 | // surface as a 500 defect, exactly as the old inline resolver let them |
| 186 | // bubble. The narrow `die` here is the runtime edge for that infra | 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 | // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). | 73 | // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). |
| 74 | const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl); | 74 | const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl); |
| 75 | 75 | ||
| 76 | // CLI device-login discovery (`executor login`). Points the CLI at Better | ||
| 77 | // Auth's device endpoints; `requestFormat: "json"` because those endpoints | ||
| 78 | // only accept JSON (unlike WorkOS's form-encoded ones). The issued token is a | ||
| 79 | // Better Auth session that `bearer()` accepts on the /api/* plane. | ||
| 80 | const cliLoginHandler = HttpEffect.fromWebHandler( | ||
| 81 | async () => | ||
| 82 | new Response( | ||
| 83 | JSON.stringify({ | ||
| 84 | provider: "better-auth", | ||
| 85 | deviceAuthorizationEndpoint: `${config.webBaseUrl}/api/auth/device/code`, | ||
| 86 | tokenEndpoint: `${config.webBaseUrl}/api/auth/device/token`, | ||
| 87 | clientId: "executor-cli", | ||
| 88 | requestFormat: "json", | ||
| 89 | }), | ||
| 90 | { headers: { "content-type": "application/json" } }, | ||
| 91 | ), | ||
| 92 | ); | ||
| 93 | |||
| 76 | const { appLayer, toWebHandler } = ExecutorApp.make({ | 94 | const { appLayer, toWebHandler } = ExecutorApp.make({ |
| 77 | plugins: selfHostPlugins, | 95 | plugins: selfHostPlugins, |
| 78 | providers: { | 96 | providers: { |
| @@ -86,7 +104,11 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 7 unmodified lines | |||
| 86 | }, | 104 | }, |
| 87 | extensions: { | 105 | extensions: { |
| 88 | routes: [ | 106 | routes: [ |
| 89 | // Better Auth owns /api/auth/* — the full path reaches it unmodified. | 107 | // CLI device-login discovery, must precede the /api/auth/* wildcard |
| 108 | // below (Better Auth would otherwise 404 it). The verification page it | ||
| 109 | // points at (/device) is a console SPA route (web/device.tsx). | ||
| 110 | HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler), | ||
| 111 | // Better Auth owns the rest of /api/auth/*, the full path reaches it. | ||
| 90 | HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)), | 112 | HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)), |
| 91 | // Browser approval of paused MCP executions: the console resume page | 113 | // Browser approval of paused MCP executions: the console resume page |
| 92 | // reads paused detail (GET) and records the decision (POST .../resume), | 114 | // reads paused detail (GET) and records the decision (POST .../resume), |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { betterAuth, type BetterAuthOptions } from "better-auth"; | 1 | import { betterAuth, type BetterAuthOptions } from "better-auth"; |
| 2 | import { APIError } from "better-auth/api"; | 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 | import { apiKey } from "@better-auth/api-key"; | 4 | import { apiKey } from "@better-auth/api-key"; |
| 5 | import { type Client } from "@libsql/client"; | 5 | import { type Client } from "@libsql/client"; |
| 6 | import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql"; | 6 | import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql"; |
| @@ -114,6 +114,14 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 107 unmodified lines | |||
| 114 | admin(), | 114 | admin(), |
| 115 | apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), | 115 | apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), |
| 116 | bearer(), | 116 | bearer(), |
| 117 | // RFC 8628 device authorization, the CLI `executor login` flow. Registers | ||
| 118 | // /device/code + /device/token + the approval endpoints; the issued token | ||
| 119 | // is an opaque session that `bearer()` (above) accepts as `Authorization: | ||
| 120 | // Bearer` on the /api/* plane. `validateClient` is left unset, so any | ||
| 121 | // client_id is accepted (the CLI presents "executor-cli"). `verificationUri` | ||
| 122 | // is the page the user opens to confirm the code — the self-host app serves | ||
| 123 | // it at /device (this is also the Better Auth default; pinned for clarity). | ||
| 124 | deviceAuthorization({ verificationUri: "/device" }), | ||
| 117 | // `consentPage` makes the MCP authorize flow redirect to a human approval | 125 | // `consentPage` makes the MCP authorize flow redirect to a human approval |
| 118 | // screen instead of auto-issuing a code — but ONLY when the request | 126 | // screen instead of auto-issuing a code — but ONLY when the request |
| 119 | // carries `prompt=consent`. MCP clients don't send that, so the self-host | 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 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; | 11 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; |
| 12 | 12 | ||
| 13 | import { authClient } from "../auth-client"; | 13 | import { authClient } from "../auth-client"; |
| 14 | import { DevicePage } from "../device"; | ||
| 14 | import { McpConsentPage } from "../mcp-consent"; | 15 | import { McpConsentPage } from "../mcp-consent"; |
| 15 | import { LoginPage } from "../login"; | 16 | import { LoginPage } from "../login"; |
| 16 | import { SetupPage } from "../setup"; | 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 | return ( | 145 | return ( |
| 131 | <AuthProvider> | 146 | <AuthProvider> |
| 132 | <AuthGate> | 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 | // vitest; pass `record` to save an asciicast v2 the viewer can replay. The | 3 | // vitest; pass `record` to save an asciicast v2 the viewer can replay. The |
| 4 | // recording is written in release so a timeout still leaves the evidence. | 4 | // recording is written in release so a timeout still leaves the evidence. |
| 5 | import { writeFileSync } from "node:fs"; | 5 | import { writeFileSync } from "node:fs"; |
| 6 | import { dirname } from "node:path"; | ||
| 6 | 7 | ||
| 7 | import { Effect } from "effect"; | 8 | import { Effect } from "effect"; |
| 8 | import { TerminalControl, type Session } from "@kitlangton/terminal-control"; | 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 | export interface CliSurface { | 13 | export interface CliSurface { |
| 13 | readonly session: <T>( | 14 | readonly session: <T>( |
| @@ -67,10 +68,16 @@ export const makeCliSurface = (): CliSurface => ({ · 53 unmodified lines | |||
| 67 | record: options?.record ? true : undefined, | 68 | record: options?.record ? true : undefined, |
| 68 | viewport: options?.viewport, | 69 | viewport: options?.viewport, |
| 69 | }); | 70 | }); |
| 70 | return { tc, session }; | 71 | // Anchor the terminal recording on the run's focus timeline (symmetric |
| 72 | // with the browser surface), so a combined cli+browser scenario gets the | ||
| 73 | // synced SessionPlayer view (terminal/browser cuts + URL bar) for free. | ||
| 74 | const runDir = options?.record ? dirname(options.record) : null; | ||
| 75 | if (runDir) markRecordingStart(runDir, "terminal"); | ||
| 76 | return { tc, session, runDir }; | ||
| 71 | }), | 77 | }), |
| 72 | ({ session }) => | 78 | ({ session, runDir }) => |
| 73 | Effect.promise(async () => { | 79 | Effect.promise(async () => { |
| 80 | if (runDir) await enterFocus(runDir, "terminal"); | ||
| 74 | const result = await drive(session); | 81 | const result = await drive(session); |
| 75 | // Hold the terminal's final frame (e.g. "connected") before the | 82 | // Hold the terminal's final frame (e.g. "connected") before the |
| 76 | // recording stops, so it isn't a single flash in the film. Filming | 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 | readonly kind: "bearer"; | 16 | readonly kind: "bearer"; |
| 17 | readonly token: string; | 17 | readonly token: string; |
| 18 | } | ||
| 19 | | { | ||
| 20 | // OAuth 2.0 device-flow credential from `executor login` against a hosted | ||
| 21 | // server. The access token is sent as a Bearer; `refreshToken` + | ||
| 22 | // `expiresAt` (epoch seconds) + `tokenEndpoint` + `clientId` let the CLI | ||
| 23 | // refresh it before expiry without a fresh browser login. | ||
| 24 | readonly kind: "oauth"; | ||
| 25 | readonly accessToken: string; | ||
| 26 | readonly refreshToken?: string; | ||
| 27 | readonly expiresAt?: number; | ||
| 28 | readonly tokenEndpoint?: string; | ||
| 29 | readonly clientId?: string; | ||
| 18 | }; | 30 | }; |
| 19 | 31 | ||
| 20 | export interface ExecutorServerConnection { | 32 | export interface ExecutorServerConnection { |
| @@ -125,6 +137,7 @@ export const getExecutorServerAuthorizationHeader = ( · 104 unmodified lines | |||
| 125 | const auth = connection.auth; | 137 | const auth = connection.auth; |
| 126 | if (!auth) return null; | 138 | if (!auth) return null; |
| 127 | if (auth.kind === "bearer") return `Bearer ${auth.token}`; | 139 | if (auth.kind === "bearer") return `Bearer ${auth.token}`; |
| 140 | if (auth.kind === "oauth") return `Bearer ${auth.accessToken}`; | ||
| 128 | const encoded = encodeBasicCredentials( | 141 | const encoded = encodeBasicCredentials( |
| 129 | `${auth.username ?? DEFAULT_EXECUTOR_SERVER_USERNAME}:${auth.password}`, | 142 | `${auth.username ?? DEFAULT_EXECUTOR_SERVER_USERNAME}:${auth.password}`, |
| 130 | ); | 143 | ); |
| @@ -141,6 +154,14 @@ const ExecutorServerAuthJson = Schema.Union([ · 10 unmodified lines | |||
| 141 | kind: Schema.Literal("bearer"), | 154 | kind: Schema.Literal("bearer"), |
| 142 | token: Schema.String, | 155 | token: Schema.String, |
| 143 | }), | 156 | }), |
| 157 | Schema.Struct({ | ||
| 158 | kind: Schema.Literal("oauth"), | ||
| 159 | accessToken: Schema.String, | ||
| 160 | refreshToken: Schema.optional(Schema.String), | ||
| 161 | expiresAt: Schema.optional(Schema.Number), | ||
| 162 | tokenEndpoint: Schema.optional(Schema.String), | ||
| 163 | clientId: Schema.optional(Schema.String), | ||
| 164 | }), | ||
| 144 | ]); | 165 | ]); |
| 145 | 166 | ||
| 146 | const ExecutorServerConnectionJson = Schema.Struct({ | 167 | const ExecutorServerConnectionJson = Schema.Struct({ |