Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions src/workos/routes/sso.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, string>;

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<string, string>;
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<string, string>;

// 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');
});
});
46 changes: 46 additions & 0 deletions src/workos/routes/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading