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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose";
4
5import { ApiKeyService } from "../auth/api-keys";
6import { UserStoreService } from "../auth/context";
7import type { JwtBearerConfig } from "../auth/workos-auth-provider";
8import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
9import { resolveProtectedPrincipal } from "./protected";
10
11const createdAt = new Date("2026-01-01T00:00:00.000Z");
12const issuer = "https://test-authkit.example.com";
13const 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`.
18const 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.
36const 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
43const 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
61const 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
88const run = (request: Request, jwt: JwtBearerConfig) =>
89 resolveProtectedPrincipal(request, jwt).pipe(
90 Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)),
91 );
92
93const request = (token: string) =>
94 new Request("https://executor.test/api/tools", {
95 headers: { authorization: `Bearer ${token}` },
96 });
97
98describe("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