fix(auth): complete device authorization grant support - #64
Conversation
Greptile SummaryThe 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.
Confidence Score: 5/5The 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
|
e163c92 to
5e810a4
Compare
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
5e810a4 to
f14c208
Compare
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.
| 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'); |
There was a problem hiding this 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:
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.
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
/user_management/authorize/deviceand/user_management/authenticatepreviously calledparseJsonBodyand rejectedapplication/x-www-form-urlencoded, which RFC 8628 §3.1/§3.2 require and which is what CLIs send. A newparseOAuthBodyhelper dispatches onContent-Type— JSON goes through the existingparseJsonBody, form-encoded throughc.req.parseBody()— and returns the sameRecord<string, unknown>the handlers already index into./user_management/authenticateaccepts both media types on every grant (Nest's defaulturlencodedparser is active, and the edge proxy documents both content types for that path), not justdevice_code— even though the docs only show form-encoded for thedevice_codeexample. Scoping form-encoded todevice_codealone would be more restrictive than production and produce false failures, so the emulator accepts form-encoded everywhere production does.verification_uriis now resolvable. It was hardcoded to the unreachablehttp://localhost:0/user_management/authorize/device/verify. The emulator'sbaseUrlis now threaded intoformatDeviceAuthorization, so the URL points at the running instance.GET /user_management/authorize/device/verifyreturning a styled HTML confirmation page. Because the emulator auto-approves device authorization with the first seeded user, the page confirms rather than collects auser_code, so a CLI that opensverification_uriin a browser no longer hits a 404. It is registered as a public path (like the/user_management/authorizelogin 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_uriwas both unresolvable and pointed at a route that didn't exist.Closes #62