History

CLI device-flow login for hosted accounts (`executor login`) (#1026)

* Add `executor login` device-flow auth for hosted accounts Let users sign the CLI into a hosted Executor account with the OAuth 2.0 Device Authorization Grant (RFC 8628) instead of hand-pasting an API key. - CLI: `executor login` / `logout` / `whoami`. `login` discovers the provider device endpoints from the server (`GET /api/auth/cli-login`), runs RFC 8628 (request code, open the browser, poll), and stores the resulting tokens in the server profile. The credential is refreshed before expiry on subsequent calls. - SDK + CLI profile: new `oauth` auth kind (access + refresh token + expiry) alongside basic/bearer; sent as `Authorization: Bearer`. - Cloud: the `/api/*` plane now accepts a provider access-token JWT as a bearer (in addition to API keys and the session cookie), reusing the existing MCP JWT verifier; plus a public `/api/auth/cli-login` discovery endpoint. - Fix: a stored credential (oauth/bearer) no longer triggers local daemon auto-start for an http://localhost server. - e2e: a cloud scenario drives the real CLI through the device flow and asserts an authenticated `/api/*` call, recorded for the viewer. The flow is provider-neutral; self-host (Better Auth device authorization) is a follow-up. Driving the cloud e2e depends on a device-flow capable build of the service emulator (developed in the fork; to be published). * CLI login: name profiles by account, not hostname Two accounts on the same host previously collided (the profile name was derived from the hostname, so a second login overwrote the first). Name the profile by the authenticated account instead: capture the email + org from the device-token response and pick the name from that, finding-or- creating so a re-login updates the same account's profile while a different account gets its own. Mirrors how opencode keys accounts (by email), and fixes the same-host clobber. Adds an e2e proving two same-host accounts yield two retained profiles. * e2e: record the browser approval; don't gate authed connections on liveness probe - The device-login e2e now drives the real verification page in Playwright (recorded to session.mp4) alongside the terminal cast, instead of approving headlessly — the recording shows the actual browser hop. - Resolving an authenticated remote connection (oauth/bearer/basic) no longer requires the unauthenticated /api/health liveness probe to pass; that probe only gates the local auto-start decision and isn't exposed by hosted servers. The real API call surfaces any connectivity/auth error. - Update the JWT auth test's org-store stub for the new org slug field. * self-host: executor login device flow (Better Auth) Brings `executor login` to the self-host app, mirroring cloud: - Add Better Auth's deviceAuthorization() plugin; the issued session token is accepted as a Bearer on /api/* via the existing bearer() plugin (no identity-layer change). - Serve GET /api/auth/cli-login discovery (provider better-auth, the /api/auth/device/{code,token} endpoints, clientId executor-cli) and a /device verification page that binds + approves the pending code with the session cookie. Dev vite forwards /device to the Effect handler. - CLI: request encoding is discovery-driven (requestFormat form|json) since Better Auth's device endpoints only accept JSON while WorkOS needs form. - e2e: a self-host scenario drives the real binary + browser approval and an authenticated /api call, recorded (terminal.cast + session.mp4). * e2e: anchor the terminal recording on the focus timeline The cli.session surface now writes a terminal anchor + focus to timeline.json (symmetric with the browser surface), so a combined cli+browser scenario produces the synced SessionPlayer view (terminal/browser cuts + the synthesized URL bar) instead of falling back to separate clips. The device-login scenarios cut focus back to the terminal after the browser approval so the final 'Logged in' + authenticated /api call land on the terminal. * address review: remove em-dashes, scope E2E_FILM, pin verificationUri - Replace em-dash characters in the comments this PR added (repo style rule). - Restore E2E_FILM after the device-login scenarios via a scope finalizer so the filmed-mode flag doesn't leak into later serial e2e files. - Pin Better Auth deviceAuthorization verificationUri to /device (its default, made explicit). * self-host device page: a console SPA route, not raw HTML Replace the server-rendered /device HTML with a real console route (web/device.tsx) rendered chromeless inside the auth gate, the same static-import + pathname-branch convention as the MCP consent screen. It uses the shared Button + design-system styles and drives Better Auth's device endpoints (claim, then approve/deny) with plain fetch. Drops the bespoke HTML route from app.ts and the /device dev-forward from vite.config.ts (the SPA now serves /device in dev and prod).

Rhys Sullivan committed 4 months ago

Commit 7c873ec1 parent 6c2478417 files · +1557 −13Browse files

Showing the changes to apps/cloud/src/api (1 of 17 files). Show all

Unified · Split

apps/cloud/src/api/protected-jwt-auth.node.test.ts+169−0
@@ -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+});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t