diff --git a/README.md b/README.md index 20c2e6a..748282a 100644 --- a/README.md +++ b/README.md @@ -801,6 +801,14 @@ 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. 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/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..fdf3518 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -606,3 +606,101 @@ 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('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); + + 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; + + // 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'); + }); +}); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 0e2472b..4ca7453 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -318,6 +318,52 @@ 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. + * + * 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. + * + * `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: `${origin}/user_management/authorize`, + token_endpoint: `${origin}/user_management/authenticate`, + response_types_supported: ['code'], + jwks_uri: `${origin}/sso/jwks/${clientId}`, + }); + }); + // SSO Single Logout — generate logout token app.post('/sso/logout/authorize', async (c) => { const body = await parseJsonBody(c);