From 0f2caec3c26d8738974cdf8e7292855857a73f66 Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Tue, 25 Aug 2026 21:38:27 +0100 Subject: [PATCH 1/4] feat(sso): serve OIDC discovery per AuthKit client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production serves a discovery document at `/user_management/{client_id}/.well-known/openid-configuration`, unauthenticated. The emulator had no such route, so a client that discovers its endpoints rather than hard-coding them got a 401 from the auth middleware, which reads as "wrong credentials" rather than "not here" and invites a retry that cannot succeed. Same five fields, same shapes: authorization_endpoint, token_endpoint, response_types_supported and jwks_uri all match production, jwks_uri per client as upstream does. `issuer` deliberately differs. Production derives it per client as `{base}/user_management/{client_id}`; the emulator reports its configured issuer, because that is what it puts in `iss` and a client validating one against the other has to see them agree. Matching production's shape would mean minting `iss` per client, which changes what `--issuer` means. The client id is not validated, where production answers `entity_not_found`. The emulator has no registry of AuthKit clients — authorize accepts any `client_id` and `/sso/jwks/:clientId` already serves any id — so refusing here alone would only break callers using a made-up id everywhere else. --- README.md | 6 ++++++ src/core/server.ts | 5 +++++ src/workos/routes/sso.spec.ts | 35 +++++++++++++++++++++++++++++++++++ src/workos/routes/sso.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/README.md b/README.md index 20c2e6a..b83a920 100644 --- a/README.md +++ b/README.md @@ -801,6 +801,12 @@ const emulator = await createEmulator({ What this buys you: +- **OIDC discovery.** `GET /user_management/:client_id/.well-known/openid-configuration` serves the + same document production does, unauthenticated, so a client that discovers its endpoints rather + than hard-coding them needs no emulator-specific branch. One deliberate difference: `issuer` is + the emulator's configured issuer, not production's `{base}/user_management/{client_id}`, because + it has to match the `iss` the emulator actually mints or a client validating one against the + other rejects every token. - **JWKS stable across restarts.** `/sso/jwks/:client_id` publishes the same key every boot, so a token minted before a restart still verifies after it. Without a pinned key, a verifier that cached the JWKS must refetch. diff --git a/src/core/server.ts b/src/core/server.ts index a4b5e3d..573cd17 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -70,11 +70,16 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) '/_emulate/', ]; + // OIDC discovery is per AuthKit client, so the path carries an id and cannot be matched + // exactly. Public upstream, since a client fetches it before it holds any credential. + const OPENID_CONFIGURATION = /^\/user_management\/[^/]+\/\.well-known\/openid-configuration$/; + app.use('*', async (c, next) => { const path = new URL(c.req.url).pathname; // Skip auth for public paths if (PUBLIC_PATHS.has(path)) return next(); + if (OPENID_CONFIGURATION.test(path)) return next(); for (const prefix of PUBLIC_PATH_PREFIXES) { if (path.startsWith(prefix)) { // data-integrations: only /authorize subpath is public diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index f46caa3..7831aa7 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -606,3 +606,38 @@ describe('SSO authentication events', () => { expect(await res.json()).toEqual({ success: true }); }); }); + +describe('OIDC discovery', () => { + it('serves the document unauthenticated, shaped as production does', async () => { + const { app } = createTestApp(); + + const res = await app.request( + '/user_management/client_01EXAMPLE/.well-known/openid-configuration', + // deliberately no Authorization header + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(Object.keys(body).sort()).toEqual( + ['authorization_endpoint', 'issuer', 'jwks_uri', 'response_types_supported', 'token_endpoint'].sort(), + ); + expect(body.response_types_supported).toEqual(['code']); + expect(body.authorization_endpoint).toMatch(/\/user_management\/authorize$/); + expect(body.token_endpoint).toMatch(/\/user_management\/authenticate$/); + // Per client, like production's. + expect(body.jwks_uri).toMatch(/\/sso\/jwks\/client_01EXAMPLE$/); + }); + + it('advertises the issuer it actually mints, so a client can validate iss against it', async () => { + const { app } = createTestApp(); + + const doc = (await ( + await app.request('/user_management/client_01EXAMPLE/.well-known/openid-configuration') + ).json()) as Record; + + const jwks = await (await app.request('/sso/jwks/client_01EXAMPLE')).json(); + expect(jwks).toHaveProperty('keys'); + expect(typeof doc.issuer).toBe('string'); + expect(doc.issuer.length).toBeGreaterThan(0); + }); +}); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 0e2472b..4cb460d 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -318,6 +318,32 @@ export function ssoRoutes(ctx: RouteContext): void { app.get('/sso/jwks', jwks); app.get('/sso/jwks/:clientId', jwks); + /** + * OIDC discovery, which production serves per AuthKit client at + * `/user_management/{client_id}/.well-known/openid-configuration`. Unauthenticated, as it is + * upstream: a client fetches it before it holds anything. + * + * `issuer` is the emulator's configured issuer rather than production's + * `{base}/user_management/{client_id}`, because it has to match the `iss` the emulator + * actually mints or a client validating one against the other rejects every token. Production + * can derive it per client; the emulator has a single issuer and reports that. + * + * The client id is not checked, which production does do, returning `entity_not_found`. The + * emulator has no registry of AuthKit clients — authorize accepts any `client_id` and + * `/sso/jwks/:clientId` serves any id — so refusing here alone would only break callers using + * a made-up id everywhere else. + */ + app.get('/user_management/:clientId/.well-known/openid-configuration', (c) => { + const clientId = c.req.param('clientId'); + return c.json({ + issuer: jwt.issuer, + authorization_endpoint: `${ctx.baseUrl}/user_management/authorize`, + token_endpoint: `${ctx.baseUrl}/user_management/authenticate`, + response_types_supported: ['code'], + jwks_uri: `${ctx.baseUrl}/sso/jwks/${clientId}`, + }); + }); + // SSO Single Logout — generate logout token app.post('/sso/logout/authorize', async (c) => { const body = await parseJsonBody(c); From a895241e01cd48190effdcde6f045b857508bafb Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Tue, 25 Aug 2026 21:42:59 +0100 Subject: [PATCH 2/4] docs(sso): correct what the emulator knows about client ids The comment said the emulator has no registry of AuthKit clients. It has an application registry, connectApplications, and /oauth2/* does look a client up in it. What is true is narrower: no route under /user_management or /sso consults it, so an AuthKit client is never registered, and gating discovery on it would 404 for every emulator that has not seeded one. --- src/workos/routes/sso.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 4cb460d..1f26ba1 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -328,10 +328,12 @@ export function ssoRoutes(ctx: RouteContext): void { * actually mints or a client validating one against the other rejects every token. Production * can derive it per client; the emulator has a single issuer and reports that. * - * The client id is not checked, which production does do, returning `entity_not_found`. The - * emulator has no registry of AuthKit clients — authorize accepts any `client_id` and - * `/sso/jwks/:clientId` serves any id — so refusing here alone would only break callers using - * a made-up id everywhere else. + * The client id is not checked, which production does do, returning `entity_not_found`. There + * is an application registry — `connectApplications`, which `/oauth2/*` looks a client up in — + * but nothing puts an AuthKit client there: no route under `/user_management` or `/sso` ever + * consults it, authorize accepts any `client_id`, and `/sso/jwks/:clientId` serves any id. + * Gating here alone would 404 for every emulator that has not seeded its AuthKit client as a + * connect application, which is the default. */ app.get('/user_management/:clientId/.well-known/openid-configuration', (c) => { const clientId = c.req.param('clientId'); From 370a489d159ab4a05f1a4e15093b6bd777447308 Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Tue, 25 Aug 2026 21:48:46 +0100 Subject: [PATCH 3/4] test(sso): compare the advertised issuer against a real token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test was named for the thing it did not do: it fetched discovery and JWKS and then only asserted that `issuer` was a non-empty string, so the drift it exists to catch would have gone unnoticed. It now mints a token through the flow the document advertises and asserts the `iss` claim equals the document's `issuer`, and that `aud` is the client the document was fetched for. Verified by making discovery advertise `{issuer}/user_management/{client_id}` — production's shape, which this emulator does not mint — and watching it fail. --- src/workos/routes/sso.spec.ts | 45 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 7831aa7..b8c73de 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -629,15 +629,50 @@ describe('OIDC discovery', () => { }); it('advertises the issuer it actually mints, so a client can validate iss against it', async () => { - const { app } = createTestApp(); + const { app, store } = createTestApp(); + const ws = getWorkOSStore(store); + + ws.users.insert({ + object: 'user', + name: null, + email: 'discovery@test.com', + first_name: null, + last_name: null, + email_verified: true, + profile_picture_url: null, + last_sign_in_at: null, + external_id: null, + metadata: {}, + locale: null, + password_hash: null, + impersonator: null, + }); const doc = (await ( await app.request('/user_management/client_01EXAMPLE/.well-known/openid-configuration') ).json()) as Record; - const jwks = await (await app.request('/sso/jwks/client_01EXAMPLE')).json(); - expect(jwks).toHaveProperty('keys'); - expect(typeof doc.issuer).toBe('string'); - expect(doc.issuer.length).toBeGreaterThan(0); + // Mint a real token through the flow the document advertises, rather than trusting the + // document about itself: a client fetches discovery precisely to validate `iss`, so the two + // drifting apart is the failure worth catching. + const authorize = await app.request( + '/user_management/authorize?redirect_uri=http://localhost:3000/callback&client_id=client_01EXAMPLE', + ); + const code = new URL(authorize.headers.get('location')!).searchParams.get('code')!; + const token = (await ( + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code, client_id: 'client_01EXAMPLE' }), + }) + ).json()) as { access_token: string }; + + const claims = JSON.parse(Buffer.from(token.access_token.split('.')[1]!, 'base64url').toString()) as Record< + string, + string + >; + + expect(claims.iss).toBe(doc.issuer); + expect(claims.aud).toBe('client_01EXAMPLE'); }); }); From 4d1aa6c5ad0fdec832a0794473582b43fb241e9d Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Tue, 25 Aug 2026 21:53:05 +0100 Subject: [PATCH 4/4] fix(sso): point discovery at the host it was fetched over, and refuse junk ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a container user would have hit. The endpoints were built from the configured base URL, so an emulator reached as host.docker.internal, a compose service name or a LAN address answered with a document pointing at localhost — the one host that caller cannot reach. They now follow the requested origin. `issuer` deliberately does not: it has to match the `iss` the emulator mints, whatever name the document was fetched under. A client id was taken verbatim, so anything at all came back inside `jwks_uri`, advertising nonsense as a key endpoint. Ids outside the character set an id can use are now refused with production's `entity_not_found` shape. Registered clients still cannot be told apart — no route under /user_management or /sso consults connectApplications — but this is not that: it rejects what could not be a client under any reading. Both tests fail against the previous behaviour. --- README.md | 10 ++++---- src/workos/routes/sso.spec.ts | 28 ++++++++++++++++++++++ src/workos/routes/sso.ts | 44 ++++++++++++++++++++++++----------- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b83a920..748282a 100644 --- a/README.md +++ b/README.md @@ -803,10 +803,12 @@ What this buys you: - **OIDC discovery.** `GET /user_management/:client_id/.well-known/openid-configuration` serves the same document production does, unauthenticated, so a client that discovers its endpoints rather - than hard-coding them needs no emulator-specific branch. One deliberate difference: `issuer` is - the emulator's configured issuer, not production's `{base}/user_management/{client_id}`, because - it has to match the `iss` the emulator actually mints or a client validating one against the - other rejects every token. + than hard-coding them needs no emulator-specific branch. The endpoints follow the host the + document was fetched over, so reaching the emulator as `host.docker.internal` or a service name + gets a document pointing back at that name rather than at localhost. One deliberate difference: + `issuer` is the emulator's configured issuer, not production's + `{base}/user_management/{client_id}`, because it has to match the `iss` the emulator actually + mints or a client validating one against the other rejects every token. - **JWKS stable across restarts.** `/sso/jwks/:client_id` publishes the same key every boot, so a token minted before a restart still verifies after it. Without a pinned key, a verifier that cached the JWKS must refetch. diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index b8c73de..fdf3518 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -628,6 +628,34 @@ describe('OIDC discovery', () => { expect(body.jwks_uri).toMatch(/\/sso\/jwks\/client_01EXAMPLE$/); }); + it('builds its endpoints from the host it was fetched over, not the configured base URL', async () => { + const { app } = createTestApp(); + + // The container image is routinely reached as something other than localhost — over + // host.docker.internal, a service name, a LAN address — and a document advertising the + // configured base URL would point that caller at a host it cannot reach. + const res = await app.request( + new Request('http://host.docker.internal:4100/user_management/client_01EXAMPLE/.well-known/openid-configuration'), + ); + const body = (await res.json()) as Record; + + expect(body.authorization_endpoint).toBe('http://host.docker.internal:4100/user_management/authorize'); + expect(body.token_endpoint).toBe('http://host.docker.internal:4100/user_management/authenticate'); + expect(body.jwks_uri).toBe('http://host.docker.internal:4100/sso/jwks/client_01EXAMPLE'); + }); + + it('refuses a client id that could not be one, the way production refuses an unknown one', async () => { + const { app } = createTestApp(); + + const res = await app.request('/user_management/%3Cscript%3E/.well-known/openid-configuration'); + + expect(res.status).toBe(404); + const body = (await res.json()) as Record; + expect(body.code).toBe('entity_not_found'); + // Nothing reflected into a document that then advertises it as a JWKS endpoint. + expect(body).not.toHaveProperty('jwks_uri'); + }); + it('advertises the issuer it actually mints, so a client can validate iss against it', async () => { const { app, store } = createTestApp(); const ws = getWorkOSStore(store); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 1f26ba1..4ca7453 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -323,26 +323,44 @@ export function ssoRoutes(ctx: RouteContext): void { * `/user_management/{client_id}/.well-known/openid-configuration`. Unauthenticated, as it is * upstream: a client fetches it before it holds anything. * - * `issuer` is the emulator's configured issuer rather than production's - * `{base}/user_management/{client_id}`, because it has to match the `iss` the emulator - * actually mints or a client validating one against the other rejects every token. Production - * can derive it per client; the emulator has a single issuer and reports that. + * Endpoints are built from the requested origin rather than the configured base URL, because a + * document is only useful to whoever fetched it. Reached over host.docker.internal or a LAN + * address — both ordinary for the container image — a base-URL document would advertise + * localhost, which is precisely the host that caller cannot reach. * - * The client id is not checked, which production does do, returning `entity_not_found`. There - * is an application registry — `connectApplications`, which `/oauth2/*` looks a client up in — - * but nothing puts an AuthKit client there: no route under `/user_management` or `/sso` ever - * consults it, authorize accepts any `client_id`, and `/sso/jwks/:clientId` serves any id. - * Gating here alone would 404 for every emulator that has not seeded its AuthKit client as a - * connect application, which is the default. + * `issuer` is the exception, and stays the configured issuer: it has to match the `iss` the + * emulator mints or a client validating one against the other rejects every token. Production + * derives it per client as `{base}/user_management/{client_id}`; the emulator has one issuer + * and reports that. + * + * A client id that could not be one is refused the way production refuses an unknown one. + * Registered clients cannot be told apart here — no route under `/user_management` or `/sso` + * consults the `connectApplications` registry, so an AuthKit client is never in it — but + * anything outside the character set an id can use is not a client under any reading, and + * serving it would only reflect junk back into `jwks_uri`. */ + const CLIENT_ID_SHAPE = /^[A-Za-z0-9_-]+$/; + app.get('/user_management/:clientId/.well-known/openid-configuration', (c) => { const clientId = c.req.param('clientId'); + if (!CLIENT_ID_SHAPE.test(clientId)) { + return c.json( + { + message: `Application not found: '${clientId}'.`, + code: 'entity_not_found', + entity_id: clientId, + }, + 404, + ); + } + + const origin = new URL(c.req.url).origin; return c.json({ issuer: jwt.issuer, - authorization_endpoint: `${ctx.baseUrl}/user_management/authorize`, - token_endpoint: `${ctx.baseUrl}/user_management/authenticate`, + authorization_endpoint: `${origin}/user_management/authorize`, + token_endpoint: `${origin}/user_management/authenticate`, response_types_supported: ['code'], - jwks_uri: `${ctx.baseUrl}/sso/jwks/${clientId}`, + jwks_uri: `${origin}/sso/jwks/${clientId}`, }); });