Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export {
unauthorized,
forbidden,
parseJsonBody,
parseOAuthBody,
} from './middleware/error-handler.js';
export {
authMiddleware,
Expand Down
22 changes: 22 additions & 0 deletions src/core/middleware/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ export async function parseJsonBody(c: Context): Promise<Record<string, unknown>
}
}

// Parses an OAuth request body whether the client sent JSON or
// application/x-www-form-urlencoded. RFC 6749 §3.2 / RFC 8628 require the form encoding, and
// WorkOS production accepts both on /user_management/authenticate for every grant (Nest's
// default urlencoded parser is active there) and on /user_management/authorize/device. Dispatch
// on Content-Type -- JSON goes through parseJsonBody, form-encoded through c.req.parseBody --
// and return the same Record<string, unknown> the handlers index into.
export async function parseOAuthBody(c: Context): Promise<Record<string, unknown>> {
const contentType = (c.req.header('content-type') ?? '').toLowerCase();
if (contentType.includes('application/json')) {
return parseJsonBody(c);
}
try {
const body = await c.req.parseBody();
if (body && typeof body === 'object' && !Array.isArray(body)) {
return body as Record<string, unknown>;
}
return {};
} catch {
throw new WorkOSApiError(400, 'Problems parsing body', 'invalid_request_body');
}
}

function errorStatus(err: unknown): number {
if (err && typeof err === 'object' && 'status' in err) {
const s = (err as { status: unknown }).status;
Expand Down
14 changes: 11 additions & 3 deletions src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { JWTManager, type SigningKeyOptions } from './jwt.js';
import { createApiErrorHandler, requestIdMiddleware } from './middleware/error-handler.js';
import { authMiddleware, type ApiKeyMap, type WorkOSAppEnv } from './middleware/auth.js';
import { errorHooksMiddleware } from './error-hooks.js';
import type { ServicePlugin } from './plugin.js';
import type { ServicePlugin, RouteContext } from './plugin.js';

export interface ServerOptions {
port?: number;
Expand All @@ -29,6 +29,11 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {})
const store = new Store();
const jwt = new JWTManager(options.issuer ?? baseUrl, options.signingKey);

// Mutable so createEmulator can reassign it to the actual bound URL after listen() resolves
// an ephemeral port (port: 0). Route handlers read `ctx.baseUrl` per-request rather than
// destructuring it, so a post-bind reassignment takes effect for them.
const ctx: RouteContext = { app, store, jwt, baseUrl };

const apiKeys: ApiKeyMap = options.apiKeys ?? {
sk_test_default: { environment: 'test' },
};
Expand All @@ -48,6 +53,9 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {})
const PUBLIC_PATHS = new Set([
'/health',
'/user_management/authorize',
// Browser-facing device verification page; like the authorize login page it cannot carry a
// bearer token, so it is public. The POST device-authorization endpoint still requires auth.
'/user_management/authorize/device/verify',
'/user_management/authenticate',
'/user_management/sessions/logout',
]);
Expand Down Expand Up @@ -127,7 +135,7 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {})
store.setData('apiKeyMap', apiKeys);

// Register plugin routes
plugin.register({ app, store, jwt, baseUrl });
plugin.register(ctx);

// Not found handler
app.notFound((c) =>
Expand All @@ -140,5 +148,5 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {})
),
);

return { app, store, jwt, port, baseUrl };
return { app, store, jwt, port, baseUrl, ctx };
}
13 changes: 13 additions & 0 deletions src/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,4 +438,17 @@ describe('end-to-end login flow (workos.com/docs story)', () => {
method: 'DELETE',
});
});

it('device authorization verification_uri resolves to the actual bound port (port: 0)', async () => {
// The emulator is booted with port: 0, so its base URL is only known after bind. The
// verification_uri must reflect that real port, not the pre-bind http://localhost:0 the
// route context started with — otherwise a CLI opening it in a browser cannot connect.
const res = await api('/user_management/authorize/device', {
method: 'POST',
body: JSON.stringify({ client_id: 'client_e2e' }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as any;
expect(body.verification_uri).toBe(`${emulator.url}/user_management/authorize/device/verify`);
});
});
20 changes: 14 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise<Emu
// the captured `apiKeys` object to this state (see below).
const initialApiKeys: ApiKeyMap = { ...apiKeys };

const { app, store, jwt } = createServer(workosPlugin, {
const { app, store, jwt, ctx } = createServer(workosPlugin, {
port,
baseUrl,
apiKeys,
Expand Down Expand Up @@ -176,15 +176,16 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise<Emu
}
};

const seedFn = () => {
workosPlugin.seed?.(store, baseUrl);
const seedFn = (seedBaseUrl: string) => {
workosPlugin.seed?.(store, seedBaseUrl);
if (options.seed) {
seedFromConfig(store, baseUrl, options.seed);
seedFromConfig(store, seedBaseUrl, options.seed);
}
seedErrorHooks();
};

seedFn();
// Seeding runs after bind (below) so URLs baked into seeded records — invitation
// accept_invitation_urls — use the actual bound port instead of the pre-bind localhost:0.

// Passing an explicit `hostname` makes `listen()` asynchronous, so we await the
// listening callback (important for port: 0) and reject if the bind fails.
Expand Down Expand Up @@ -214,6 +215,13 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise<Emu
// issuer is left alone — the whole point is that it does not move with the port.
if (!options.issuer) jwt.issuer = url;

// Reflect the actual bound URL on the route context too (matters when port: 0), so
// per-request URLs like the device verification_uri and SSO logout_url resolve to a
// reachable address instead of the pre-bind http://localhost:0. Seed afterward so seeded
// invitation accept_invitation_urls are baked with the bound port as well.
ctx.baseUrl = url;
seedFn(url);

const primaryApiKey = Object.keys(apiKeys)[0];

return {
Expand All @@ -237,7 +245,7 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise<Emu
Object.assign(apiKeys, initialApiKeys);
store.setData(STORE_KEYS.apiKeyMap, apiKeys);
applyOptionData();
seedFn();
seedFn(url);
// Note: EventBus is not re-registered after reset because Hono's router
// cannot be modified after it's built. Route-level authentication events
// will not work after reset. This is acceptable for test scenarios where
Expand Down
4 changes: 2 additions & 2 deletions src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,11 +847,11 @@ export function formatRoleAssignment(ra: WorkOSRoleAssignment): Record<string, u
return formatEntity(ra);
}

export function formatDeviceAuthorization(d: WorkOSDeviceAuthorization): Record<string, unknown> {
export function formatDeviceAuthorization(d: WorkOSDeviceAuthorization, baseUrl: string): Record<string, unknown> {
return {
device_code: d.device_code,
user_code: d.user_code,
verification_uri: 'http://localhost:0/user_management/authorize/device/verify',
verification_uri: `${baseUrl}/user_management/authorize/device/verify`,
expires_in: Math.max(0, Math.floor((new Date(d.expires_at).getTime() - Date.now()) / 1000)),
interval: d.interval,
};
Expand Down
35 changes: 35 additions & 0 deletions src/workos/login-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@ export interface LoginPageOptions {
hiddenFields: Record<string, string>;
}

export interface DeviceVerifyPageOptions {
title: string;
message: string;
}

// The emulator auto-approves device authorization with the first seeded user, so this page is a
// static confirmation rather than a user_code entry form. It matches the login page styling so a
// CLI that opens verification_uri in a browser lands on something that looks like WorkOS.
export function renderDeviceVerifyPage(options: DeviceVerifyPageOptions): string {
const { title, message } = options;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)} — WorkOS Emulate</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f5f5f5;display:flex;justify-content:center;align-items:center;min-height:100vh}
.card{background:#fff;border-radius:8px;padding:40px;width:400px;box-shadow:0 2px 8px rgba(0,0,0,.1)}
.badge{display:inline-block;background:#6366f1;color:#fff;font-size:11px;font-weight:600;padding:3px 8px;border-radius:4px;margin-bottom:16px;letter-spacing:.5px}
h1{font-size:22px;font-weight:600;margin-bottom:8px}
.sub{color:#6b7280;font-size:14px;line-height:1.5}
</style>
</head>
<body>
<div class="card">
<div class="badge">WORKOS EMULATE</div>
<h1>${esc(title)}</h1>
<p class="sub">${esc(message)}</p>
</div>
</body>
</html>`;
}

export function renderLoginPage(options: LoginPageOptions): string {
const { title, subtitle, emailHint, formAction, hiddenFields } = options;

Expand Down
82 changes: 82 additions & 0 deletions src/workos/routes/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,88 @@ describe('Auth routes', () => {
expect(tokenBody.user.email).toBe('device@test.com');
});

it('device authorization derives verification_uri from the server baseUrl', async () => {
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');
Comment on lines +1027 to +1034

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.

});

it('GET /user_management/authorize/device/verify serves an HTML confirmation page', async () => {
const res = await app.request('/user_management/authorize/device/verify');
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/html');
const html = await res.text();
expect(html).toContain('Device approved');
});

it('device authorization accepts form-encoded bodies', async () => {
await createUser('formdevice@test.com');
const res = await app.request('/user_management/authorize/device', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_test_auth',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ client_id: 'test_client' }).toString(),
});
expect(res.status).toBe(200);
const body = await json(res);
expect(body.device_code).toBeDefined();
expect(body.user_code).toBeDefined();
});

it('device_code grant accepts form-encoded bodies', async () => {
await createUser('formgrant@test.com');
const start = await req('/user_management/authorize/device', {
method: 'POST',
body: JSON.stringify({ client_id: 'test_client' }),
});
const { device_code } = await json(start);

const res = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code,
}).toString(),
});
expect(res.status).toBe(200);
const body = await json(res);
expect(body.access_token).toBeDefined();
expect(body.user.email).toBe('formgrant@test.com');
});

it('password grant accepts form-encoded bodies', async () => {
await req('/user_management/users', {
method: 'POST',
body: JSON.stringify({ email: 'formpass@test.com', password: 'secret' }),
});

const res = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'password',
email: 'formpass@test.com',
password: 'secret',
}).toString(),
});
// Production accepts form-encoded on every /authenticate grant (Nest's default urlencoded
// parser is active), not just device_code, so the emulator mirrors that rather than being
// more restrictive and producing a false failure.
expect(res.status).toBe(200);
const body = await json(res);
expect(body.access_token).toBeDefined();
expect(body.user.email).toBe('formpass@test.com');
expect(body.authentication_method).toBe('Password');
});

// --- Organization selection grant tests ---

it('organization-selection grant scopes session to selected org', async () => {
Expand Down
30 changes: 25 additions & 5 deletions src/workos/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import {
type RouteContext,
notFound,
parseJsonBody,
parseOAuthBody,
WorkOSApiError,
OauthApiError,
generateId,
Expand Down Expand Up @@ -33,7 +33,7 @@ import { renderConfiguredJwtTemplate } from '../jwt-template.js';
import type { EventBus } from '../event-bus.js';
import type { WorkOSInvitation } from '../entities.js';
import { STORE_KEYS, STORE_KEY_PREFIXES } from '../constants.js';
import { renderLoginPage } from '../login-page.js';
import { renderLoginPage, renderDeviceVerifyPage } from '../login-page.js';

interface PendingAuth {
user_id: string;
Expand Down Expand Up @@ -170,9 +170,22 @@ export function authRoutes(ctx: RouteContext): void {
});
});

// Device verification page. The emulator auto-approves device authorization with the first
// seeded user, so this is a confirmation page rather than a user_code entry form. It exists so
// the resolvable verification_uri returned by the authorize endpoint does not 404 in a browser.
app.get('/user_management/authorize/device/verify', (c) => {
return c.html(
renderDeviceVerifyPage({
title: 'Device approved',
message:
'WorkOS Emulate auto-approves device authorization with the first seeded user, so polling /user_management/authenticate will succeed immediately.',
}),
);
});

// Device authorization endpoint
app.post('/user_management/authorize/device', async (c) => {
const body = await parseJsonBody(c);
const body = await parseOAuthBody(c);
const clientId = body.client_id as string;
if (!clientId) {
throw new WorkOSApiError(400, 'client_id is required', 'invalid_request');
Expand All @@ -191,12 +204,19 @@ export function authRoutes(ctx: RouteContext): void {
interval: 5,
});

return c.json(formatDeviceAuthorization(deviceAuth));
return c.json(formatDeviceAuthorization(deviceAuth, ctx.baseUrl));
});

// AuthKit SDK uses /x/authkit/users/authenticate for the same flow
const authenticateHandler = async (c: any) => {
const body = await parseJsonBody(c);
// Production's /user_management/authenticate accepts both JSON and
// application/x-www-form-urlencoded on every grant (Nest's default urlencoded parser is
// active, and the edge proxy documents both media types for this path) -- not just
// device_code, despite the docs only showing form-encoded for that grant. RFC 6749 §3.2
// requires the form encoding. parseOAuthBody dispatches on Content-Type and returns the
// same Record<string, unknown> the grant handlers index into, so every grant parses
// identically to production.
const body = await parseOAuthBody(c);
const grantType = body.grant_type as string | undefined;
const clientId = body.client_id as string | undefined;
const clientSecret = body.client_secret as string | undefined;
Expand Down
6 changes: 3 additions & 3 deletions src/workos/routes/invitations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { EventBus } from '../event-bus.js';
import { STORE_KEYS, EVENTS } from '../constants.js';

export function invitationRoutes(ctx: RouteContext): void {
const { app, store, baseUrl } = ctx;
const { app, store } = ctx;
const ws = getWorkOSStore(store);

app.post('/user_management/invitations', async (c) => {
Expand All @@ -33,7 +33,7 @@ export function invitationRoutes(ctx: RouteContext): void {
email,
state: 'pending',
token,
accept_invitation_url: `${baseUrl}/user_management/invitations/accept?token=${token}`,
accept_invitation_url: `${ctx.baseUrl}/user_management/invitations/accept?token=${token}`,
organization_id: (body.organization_id as string) ?? null,
inviter_user_id: (body.inviter_user_id as string) ?? null,
role_slug: (body.role_slug as string) ?? null,
Expand Down Expand Up @@ -114,7 +114,7 @@ export function invitationRoutes(ctx: RouteContext): void {
const newToken = generateVerificationToken();
ws.invitations.update(inv.id, {
token: newToken,
accept_invitation_url: `${baseUrl}/user_management/invitations/accept?token=${newToken}`,
accept_invitation_url: `${ctx.baseUrl}/user_management/invitations/accept?token=${newToken}`,
expires_at: expiresIn(72 * 60),
state: 'pending',
});
Expand Down
Loading