From 163f02651d0ba4d20847e5e3e9e1896070b84ab4 Mon Sep 17 00:00:00 2001 From: m-mohamed Date: Sun, 30 Aug 2026 21:26:24 -0700 Subject: [PATCH 1/2] fix(workos): resolve connect applications by client id --- src/workos/routes/connect.spec.ts | 43 ++++++++++++++++++++++++++++++- src/workos/routes/connect.ts | 5 ++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/workos/routes/connect.spec.ts b/src/workos/routes/connect.spec.ts index caf1189..658b9fb 100644 --- a/src/workos/routes/connect.spec.ts +++ b/src/workos/routes/connect.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'bun:test'; import { createServer, type ApiKeyMap } from '../../core/index.js'; import { workosPlugin } from '../index.js'; +import { getWorkOSStore } from '../store.js'; const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; @@ -11,9 +12,12 @@ function createTestApp() { describe('Connect routes', () => { let app: ReturnType['app']; + let store: ReturnType['store']; beforeEach(() => { - app = createTestApp().app; + const testApp = createTestApp(); + app = testApp.app; + store = testApp.store; }); const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); @@ -98,11 +102,48 @@ describe('Connect routes', () => { expect((await json(res)).name).toBe('Get Test'); }); + it('gets an application by client_id', async () => { + const createRes = await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Client ID Get Test' }), + }); + const created = await json(createRes); + + const res = await req(`/connect/applications/${created.client_id}`); + expect(res.status).toBe(200); + expect((await json(res)).id).toBe(created.id); + }); + + it('prefers an application id over another application client_id with the same value', async () => { + const idOwner = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'ID Owner' }), + }), + ); + const clientIdOwner = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Client ID Owner' }), + }), + ); + getWorkOSStore(store).connectApplications.update(clientIdOwner.id, { client_id: idOwner.id }); + + const res = await req(`/connect/applications/${idOwner.id}`); + expect(res.status).toBe(200); + expect((await json(res)).name).toBe('ID Owner'); + }); + it('returns 404 for nonexistent application', async () => { const res = await req('/connect/applications/connect_app_nonexistent'); expect(res.status).toBe(404); }); + it('returns 404 for nonexistent client_id', async () => { + const res = await req('/connect/applications/client_nonexistent'); + expect(res.status).toBe(404); + }); + it('lists applications', async () => { await req('/connect/applications', { method: 'POST', diff --git a/src/workos/routes/connect.ts b/src/workos/routes/connect.ts index 16b45c5..88a19cc 100644 --- a/src/workos/routes/connect.ts +++ b/src/workos/routes/connect.ts @@ -70,8 +70,9 @@ export function connectRoutes(ctx: RouteContext): void { }); // Get application - app.get('/connect/applications/:id', (c) => { - const application = ws.connectApplications.get(c.req.param('id')); + app.get('/connect/applications/:ref', (c) => { + const ref = c.req.param('ref'); + const application = ws.connectApplications.get(ref) ?? ws.connectApplications.findOneBy('client_id', ref); if (!application) throw notFound('ConnectApplication'); return c.json(formatConnectApplication(application)); }); From 61ce48a764507136f8542ea7f28e4a9cd5cbeac5 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 14:27:01 -0400 Subject: [PATCH 2/2] fix(connect): resolve client_secrets route by client ID too The spec documents `{id}` on every /connect/applications/{id}... route as "the application ID or client ID", so secret creation must accept a client_id the same way the GET now does. Hoist a single resolver so both handlers share ID-first precedence, keep the param named `:id` to match the spec and the sibling route, and assert the collision test actually set up a colliding client_id before checking precedence. --- src/workos/routes/connect.spec.ts | 17 +++++++++++++++++ src/workos/routes/connect.ts | 13 +++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/workos/routes/connect.spec.ts b/src/workos/routes/connect.spec.ts index 658b9fb..2f81718 100644 --- a/src/workos/routes/connect.spec.ts +++ b/src/workos/routes/connect.spec.ts @@ -128,6 +128,8 @@ describe('Connect routes', () => { }), ); getWorkOSStore(store).connectApplications.update(clientIdOwner.id, { client_id: idOwner.id }); + // Sanity-check the collision is real: the client_id index now resolves to the other app. + expect(getWorkOSStore(store).connectApplications.findOneBy('client_id', idOwner.id)?.id).toBe(clientIdOwner.id); const res = await req(`/connect/applications/${idOwner.id}`); expect(res.status).toBe(200); @@ -180,4 +182,19 @@ describe('Connect routes', () => { const delRes = await req(`/connect/client_secrets/${secret.id}`, { method: 'DELETE' }); expect(delRes.status).toBe(204); }); + + it('creates a client secret for an application referenced by client_id', async () => { + const application = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Client ID Secret Test' }), + }), + ); + + const res = await req(`/connect/applications/${application.client_id}/client_secrets`, { method: 'POST' }); + expect(res.status).toBe(201); + const secret = await json(res); + expect(secret.object).toBe('client_secret'); + expect(secret.application_id).toBe(application.id); + }); }); diff --git a/src/workos/routes/connect.ts b/src/workos/routes/connect.ts index 88a19cc..ef6574e 100644 --- a/src/workos/routes/connect.ts +++ b/src/workos/routes/connect.ts @@ -12,6 +12,12 @@ export function connectRoutes(ctx: RouteContext): void { const { app, store } = ctx; const ws = getWorkOSStore(store); + // The spec documents the `{id}` param on every `/connect/applications/{id}...` route as + // "the application ID or client ID". Resolve the primary key first so an application ID + // always wins over another application's colliding client_id. + const findApplication = (ref: string) => + ws.connectApplications.get(ref) ?? ws.connectApplications.findOneBy('client_id', ref); + // List applications app.get('/connect/applications', (c) => { const url = new URL(c.req.url); @@ -70,16 +76,15 @@ export function connectRoutes(ctx: RouteContext): void { }); // Get application - app.get('/connect/applications/:ref', (c) => { - const ref = c.req.param('ref'); - const application = ws.connectApplications.get(ref) ?? ws.connectApplications.findOneBy('client_id', ref); + app.get('/connect/applications/:id', (c) => { + const application = findApplication(c.req.param('id')); if (!application) throw notFound('ConnectApplication'); return c.json(formatConnectApplication(application)); }); // Create client secret app.post('/connect/applications/:id/client_secrets', (c) => { - const application = ws.connectApplications.get(c.req.param('id')); + const application = findApplication(c.req.param('id')); if (!application) throw notFound('ConnectApplication'); const value = `secret_${generateVerificationToken()}`;