* 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 | +}); | |