Skip to content

fix(auth): complete device authorization grant support - #64

Merged
gjtorikian merged 4 commits into
mainfrom
fix/device-auth-grant
Aug 13, 2026
Merged

fix(auth): complete device authorization grant support#64
gjtorikian merged 4 commits into
mainfrom
fix/device-auth-grant

Conversation

@gjtorikian

@gjtorikian gjtorikian commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

The device authorization grant (RFC 8628) was partially implemented, leaving CLIs that follow the spec unable to test it against the emulator (#62).

What changed

  • Form-encoded bodies now accepted on both endpoints, for every grant. /user_management/authorize/device and /user_management/authenticate previously called parseJsonBody and rejected application/x-www-form-urlencoded, which RFC 8628 §3.1/§3.2 require and which is what CLIs send. A new parseOAuthBody helper dispatches on Content-Type — JSON goes through the existing parseJsonBody, form-encoded through c.req.parseBody() — and returns the same Record<string, unknown> the handlers already index into.
    • This mirrors production, verified against the WorkOS API source: /user_management/authenticate accepts both media types on every grant (Nest's default urlencoded parser is active, and the edge proxy documents both content types for that path), not just device_code — even though the docs only show form-encoded for the device_code example. Scoping form-encoded to device_code alone would be more restrictive than production and produce false failures, so the emulator accepts form-encoded everywhere production does.
  • verification_uri is now resolvable. It was hardcoded to the unreachable http://localhost:0/user_management/authorize/device/verify. The emulator's baseUrl is now threaded into formatDeviceAuthorization, so the URL points at the running instance.
  • Verify page served. Added GET /user_management/authorize/device/verify returning a styled HTML confirmation page. Because the emulator auto-approves device authorization with the first seeded user, the page confirms rather than collects a user_code, so a CLI that opens verification_uri in a browser no longer hits a 404. It is registered as a public path (like the /user_management/authorize login page), since a browser cannot send a bearer token.

Why

Device authorization is the standard grant for CLIs and other input-constrained devices. Without form-encoded support, a spec-compliant CLI couldn't even start the flow against the emulator; and the returned verification_uri was both unresolvable and pointed at a route that didn't exist.

Closes #62

@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown

Greptile Summary

The PR completes device authorization grant support by accepting form-encoded OAuth requests, returning a verification URL based on the actual bound server address, and serving a public confirmation page.

  • Adds a shared JSON/form OAuth body parser and applies it to device authorization and authentication grants.
  • Propagates the post-bind URL through the mutable route context and seed lifecycle.
  • Adds route-level and real-HTTP coverage for form requests, the verification page, and ephemeral ports.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the actual bound URL now reaches the device authorization handler through the shared mutable context, and real-HTTP coverage verifies the ephemeral-port path.

Important Files Changed

Filename Overview
src/index.ts Updates the shared route context and seeding with the actual post-bind URL, resolving the previously reported ephemeral-port failure.
src/core/server.ts Introduces the mutable route context used after binding and makes the static browser verification endpoint public.
src/core/middleware/error-handler.ts Adds OAuth request parsing for JSON and form-encoded bodies while preserving standardized parse errors.
src/workos/routes/auth.ts Uses the OAuth parser, derives device verification URLs from the live route context, and serves the confirmation page.
src/e2e.spec.ts Exercises device authorization over real HTTP with port 0 and verifies the returned URL uses the actual bound address.
src/workos/routes/auth.spec.ts Covers form-encoded device and password grants, base-URL derivation, and the verification page.
src/workos/login-page.ts Adds an escaped static HTML renderer for the device approval confirmation.
src/workos/helpers.ts Formats device authorization responses using the supplied runtime base URL.

Sequence Diagram

sequenceDiagram
  participant CLI
  participant Emulator
  participant DeviceRoute
  participant Browser
  CLI->>Emulator: POST /authorize/device (form encoded)
  Emulator->>DeviceRoute: Parse OAuth body
  DeviceRoute-->>CLI: device_code + bound verification_uri
  CLI->>Browser: Open verification_uri
  Browser->>Emulator: GET /authorize/device/verify
  Emulator-->>Browser: Device approved page
  CLI->>Emulator: POST /authenticate (device_code)
  Emulator-->>CLI: Access token
Loading

Reviews (5): Last reviewed commit: "fix(server): reflect bound port in route..." | Re-trigger Greptile

Comment thread src/workos/routes/auth.spec.ts Outdated
@gjtorikian
gjtorikian force-pushed the fix/device-auth-grant branch from e163c92 to 5e810a4 Compare August 13, 2026 14:15
The device authorization grant (RFC 8628) was partially broken in two
ways (#62):

1. '/user_management/authorize/device' and '/user_management/authenticate'
   only accepted JSON bodies, but RFC 8628 requires
   application/x-www-form-urlencoded -- the format CLIs send. WorkOS
   production accepts both formats on these endpoints for every grant
   (Nest's default urlencoded parser is active on /authenticate, and the
   edge proxy documents both media types for that path), not just
   device_code, so the emulator mirrors that rather than being more
   restrictive.

   Add a parseOAuthBody helper that dispatches on Content-Type (JSON via
   the existing parseJsonBody, form-encoded via c.req.parseBody) and
   returns the same Record<string, unknown> the handlers index into. Use
   it on both endpoints.

2. verification_uri was hardcoded to the unresolvable
   http://localhost:0/user_management/authorize/device/verify and no route
   served that path, so a CLI opening it in a browser hit a 404. Thread the
   emulator's baseUrl into formatDeviceAuthorization so the URL resolves,
   and add a GET confirmation page. The emulator auto-approves device
   authorization with the first seeded user, so the page confirms rather
   than collects a user_code. It is public like the authorize login page,
   since browsers cannot send a bearer token.

Closes #62
@gjtorikian
gjtorikian force-pushed the fix/device-auth-grant branch from 5e810a4 to f14c208 Compare August 13, 2026 14:42
Removed comments explaining the verification URI for device authorization.
The 'returns a resolvable verification_uri' test asserted the literal
http://localhost:0/... the suite uses as a baseUrl sentinel, so it was
tautological: a regression to the old hardcoded localhost:0 literal would
still pass. Use a distinctive baseUrl (http://localhost:9999) so the
assertion can actually distinguish derivation from the hardcoded value, and
drop the now-redundant verification_uri assertion from the form-encoded
test.

Addresses greptile review comment r3775978438.
Comment on lines +1027 to +1034
const local = createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:9999', apiKeys });
const res = await local.app.request('/user_management/authorize/device', {
method: 'POST',
headers,
body: JSON.stringify({ client_id: 'test_client' }),
});
expect(res.status).toBe(200);
expect((await json(res)).verification_uri).toBe('http://localhost:9999/user_management/authorize/device/verify');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Ephemeral verification URI remains unreachable

When the emulator starts with port 0, the route context retains the pre-bind http://localhost:0 base URL after the actual port is resolved, causing device authorization to return a verification_uri that browsers cannot reach. This test substitutes another non-listening address and checks only string interpolation, so it still passes without covering the broken startup path.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/routes/auth.spec.ts
Line: 1027-1034

Comment:
**Ephemeral verification URI remains unreachable**

When the emulator starts with port `0`, the route context retains the pre-bind `http://localhost:0` base URL after the actual port is resolved, causing device authorization to return a `verification_uri` that browsers cannot reach. This test substitutes another non-listening address and checks only string interpolation, so it still passes without covering the broken startup path.

**Knowledge Base Used:**
- [Core runtime and server machinery](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/emulate/-/docs/core-runtime-and-server.md)
- [Authentication and session flows](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/emulate/-/docs/authentication-and-session-flows.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

When the emulator started with --port 0, the route context retained the
pre-bind http://localhost:0 baseUrl after the OS assigned an actual port,
so per-request URLs built from it -- device verification_uri, SSO
logout_url, seeded invitation accept_invitation_urls -- pointed at an
unreachable address. createEmulator already corrected the JWT issuer this
way (jwt.issuer = url); baseUrl was missed.

Build the route context as a named mutable object in createServer and
return it, so createEmulator can reassign ctx.baseUrl to the bound url
after listen(). Route handlers now read ctx.baseUrl per-request instead of
destructuring it (auth, invitations; sso already did), so the reassignment
takes effect. Move seeding to after the reassignment so seeded URLs are
baked with the bound port too.

Adds an e2e test that boots createEmulator with port: 0 and asserts
verification_uri uses the actual bound port -- the unit test only checks
derivation from a configured baseUrl, not the bind lifecycle.

Addresses greptile review comment r3776424027.
@gjtorikian
gjtorikian merged commit 524ed5d into main Aug 13, 2026
9 checks passed
@gjtorikian
gjtorikian deleted the fix/device-auth-grant branch August 13, 2026 15:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Support for device authentication

1 participant