* 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).
Showing the changes to apps/cloud/src/api (1 of 17 files). Show all
Unified · Split
| @@ -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 | }); | ||