diff --git a/packages/@emulators/workos/src/__tests__/workos.test.ts b/packages/@emulators/workos/src/__tests__/workos.test.ts
index dcd65e3c..18791e7a 100644
--- a/packages/@emulators/workos/src/__tests__/workos.test.ts
+++ b/packages/@emulators/workos/src/__tests__/workos.test.ts
@@ -545,4 +545,82 @@ describe("workos emulator with the real @workos-inc/node SDK", () => {
});
expect(auth.user.firstName).toBe("Seeded");
});
+
+ it("binds an access token to the RFC 8707 resource a client asked for", async () => {
+ const redirectUri = "http://127.0.0.1:9/callback";
+ const resource = "https://resource.example";
+ const registered = (await (
+ await fetch(`${BASE}/oauth2/register`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ client_name: "resource-test", redirect_uris: [redirectUri] }),
+ })
+ ).json()) as { client_id: string };
+
+ const mint = async (
+ withResource: boolean,
+ ): Promise<{ access_token?: string; refresh_token?: string }> => {
+ const authorize = new URL(`${BASE}/oauth2/authorize`);
+ authorize.searchParams.set("client_id", registered.client_id);
+ authorize.searchParams.set("redirect_uri", redirectUri);
+ authorize.searchParams.set("login_hint", "resource@example.com");
+ authorize.searchParams.set("scope", "openid offline_access");
+ if (withResource) authorize.searchParams.set("resource", resource);
+ const redirect = await fetch(authorize, { redirect: "manual" });
+ const code = new URL(redirect.headers.get("location") ?? "").searchParams.get("code") ?? "";
+ return (await (
+ await fetch(`${BASE}/oauth2/token`, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: redirectUri,
+ client_id: registered.client_id,
+ }),
+ })
+ ).json()) as { access_token?: string; refresh_token?: string };
+ };
+
+ const jwks = createRemoteJWKSet(new URL(`${BASE}/oauth2/jwks`));
+
+ // The point of RFC 8707: the audience is the resource, NOT the id this
+ // client was assigned at registration, so a resource server admits a
+ // dynamically registered client without knowing anything about it.
+ const bound = await mint(true);
+ const verified = await jwtVerify(bound.access_token ?? "", jwks, {
+ issuer: BASE,
+ audience: resource,
+ });
+ expect(verified.payload.aud).toBe(resource);
+
+ // A refresh keeps the binding, or a renewed token stops being accepted by
+ // the resource server that accepted the first one.
+ const refreshed = (await (
+ await fetch(`${BASE}/oauth2/token`, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: bound.refresh_token ?? "",
+ client_id: registered.client_id,
+ }),
+ })
+ ).json()) as { access_token?: string };
+ const reverified = await jwtVerify(refreshed.access_token ?? "", jwks, {
+ issuer: BASE,
+ audience: resource,
+ });
+ expect(reverified.payload.aud).toBe(resource);
+
+ // Without a resource indicator the prior behaviour stands: the audience is
+ // the requesting client, which is what unmodified callers still rely on.
+ const unbound = await mint(false);
+ const unboundPayload = await jwtVerify(unbound.access_token ?? "", jwks, {
+ issuer: BASE,
+ audience: registered.client_id,
+ });
+ expect(unboundPayload.payload.aud).toBe(registered.client_id);
+ });
+
});
diff --git a/packages/@emulators/workos/src/entities.ts b/packages/@emulators/workos/src/entities.ts
index ba6df69c..cff2b326 100644
--- a/packages/@emulators/workos/src/entities.ts
+++ b/packages/@emulators/workos/src/entities.ts
@@ -74,6 +74,11 @@ export interface WorkosSession extends Entity {
revoked: boolean;
/** Space-delimited granted scopes (null for user-management sessions). */
scope: string | null;
+ /**
+ * RFC 8707 resource this session's access tokens are bound to, so a refresh
+ * renews a token the same resource server still accepts.
+ */
+ resource?: string | null;
}
/** Vault KV object. */
@@ -111,5 +116,7 @@ export interface WorkosOAuthCode extends Entity {
code_challenge: string | null;
/** Space-delimited scopes the client requested at /oauth2/authorize. */
scope: string | null;
+ /** RFC 8707 resource indicator the client asked the token to be bound to. */
+ resource: string | null;
used: boolean;
}
diff --git a/packages/@emulators/workos/src/routes/oauth.ts b/packages/@emulators/workos/src/routes/oauth.ts
index cdead83a..8e1b6706 100644
--- a/packages/@emulators/workos/src/routes/oauth.ts
+++ b/packages/@emulators/workos/src/routes/oauth.ts
@@ -70,6 +70,9 @@ export function oauthRoutes(ctx: RouteContext): void {
const state = c.req.query("state") ?? "";
const codeChallenge = c.req.query("code_challenge") ?? "";
const scope = c.req.query("scope") ?? "";
+ // RFC 8707: the resource the client wants the token bound to. Carried
+ // through the code so the token endpoint can set it as the audience.
+ const resource = c.req.query("resource") ?? "";
const loginHint = c.req.query("login_hint");
if (!redirectUri) return workosError(c, 422, "invalid_request", "redirect_uri is required");
@@ -87,6 +90,7 @@ export function oauthRoutes(ctx: RouteContext): void {
redirect_uri: redirectUri,
code_challenge: codeChallenge || null,
scope: scope || null,
+ resource: resource || null,
used: false,
});
const target = new URL(redirectUri);
@@ -113,6 +117,7 @@ export function oauthRoutes(ctx: RouteContext): void {
state,
code_challenge: codeChallenge,
scope,
+ resource,
},
}),
)
@@ -124,6 +129,7 @@ export function oauthRoutes(ctx: RouteContext): void {
+
`;
@@ -153,6 +159,7 @@ export function oauthRoutes(ctx: RouteContext): void {
redirect_uri: redirectUri,
code_challenge: String(form.code_challenge ?? "") || null,
scope: String(form.scope ?? "") || null,
+ resource: String(form.resource ?? "") || null,
used: false,
});
const target = new URL(redirectUri);
@@ -244,8 +251,12 @@ export function oauthRoutes(ctx: RouteContext): void {
client_id: session.client_id,
revoked: false,
scope: session.scope,
+ resource: session.resource ?? null,
});
- const audience = process.env.EMULATE_WORKOS_AUDIENCE ?? session.client_id;
+ // A refresh renews the binding the session was established with, so a
+ // token that reached a resource server before still reaches it after.
+ const audience =
+ session.resource ?? process.env.EMULATE_WORKOS_AUDIENCE ?? session.client_id;
const expiresIn = ttlFor(session.client_id);
const accessToken = await signAccessToken(
{
@@ -274,10 +285,14 @@ export function oauthRoutes(ctx: RouteContext): void {
return workosError(c, 400, "invalid_grant", "The code is invalid or has been used.");
}
ws().oauthCodes.update(oauthCode.id, { used: true });
- // Resource servers verify audience against THEIR WorkOS client id, not the
- // DCR client's — mirror AuthKit: EMULATE_WORKOS_AUDIENCE (the app's client
- // id) when set, else the requesting client.
- const audience = process.env.EMULATE_WORKOS_AUDIENCE ?? oauthCode.client_id;
+ // RFC 8707 first: a client that named a resource gets a token bound to it,
+ // which is what lets any dynamically registered client reach a resource
+ // server without that server knowing the client's id. Otherwise the
+ // resource server verifies against THEIR WorkOS client id, not the DCR
+ // client's — mirror AuthKit: EMULATE_WORKOS_AUDIENCE (the app's client id)
+ // when set, else the requesting client.
+ const audience =
+ oauthCode.resource ?? process.env.EMULATE_WORKOS_AUDIENCE ?? oauthCode.client_id;
// AuthKit grants exactly the scopes the client requested (no defaulting)
// and issues a refresh token ONLY when offline_access is among them. A
// client that requests no scopes gets granted_scopes: [] and a session it
@@ -293,6 +308,7 @@ export function oauthRoutes(ctx: RouteContext): void {
client_id: oauthCode.client_id,
revoked: false,
scope: oauthCode.scope,
+ resource: oauthCode.resource,
})
: null;
const expiresIn = ttlFor(oauthCode.client_id);
diff --git a/skills/workos/SKILL.md b/skills/workos/SKILL.md
index 55772a6a..5f2684c4 100644
--- a/skills/workos/SKILL.md
+++ b/skills/workos/SKILL.md
@@ -39,7 +39,7 @@ Sealed sessions are sealed and unsealed locally by the SDK with your cookie pass
## Other surfaces
-- OAuth authorization server for MCP clients: `/.well-known/oauth-authorization-server`, `/oauth2/register`, `/oauth2/authorize`, `/oauth2/token`, `/oauth2/jwks`. Set `EMULATE_WORKOS_AUDIENCE` to control the `aud` claim resource servers verify. AuthKit-faithful scope handling: the token grant carries exactly the scopes the client requested at `/oauth2/authorize`, a refresh token is issued only when `offline_access` is among them, and refresh tokens are single use (rotated on every redemption). Register with the emulate-only DCR field `access_token_ttl_seconds` to compress access-token expiry for lifecycle tests, or seed `{ "oauth": { "default_access_token_ttl_seconds": 15 } }` to compress it for every plain-DCR client (real MCP clients that cannot carry the extension); seed `null` to restore the default 3600.
+- OAuth authorization server for MCP clients: `/.well-known/oauth-authorization-server`, `/oauth2/register`, `/oauth2/authorize`, `/oauth2/token`, `/oauth2/jwks`. A client that sends an RFC 8707 `resource` indicator to `/oauth2/authorize` gets a token whose `aud` is that resource, and a refresh keeps the binding — which is what lets a dynamically registered client reach a resource server that has never seen its client id. Otherwise set `EMULATE_WORKOS_AUDIENCE` to control the `aud` claim resource servers verify; failing both, `aud` is the requesting client. AuthKit-faithful scope handling: the token grant carries exactly the scopes the client requested at `/oauth2/authorize`, a refresh token is issued only when `offline_access` is among them, and refresh tokens are single use (rotated on every redemption). Register with the emulate-only DCR field `access_token_ttl_seconds` to compress access-token expiry for lifecycle tests, or seed `{ "oauth": { "default_access_token_ttl_seconds": 15 } }` to compress it for every plain-DCR client (real MCP clients that cannot carry the extension); seed `null` to restore the default 3600.
- Organization domains: `POST /organization_domains`, `GET /organization_domains/:id`, `POST /organization_domains/:id/verify`, `DELETE /organization_domains/:id`. Domains begin pending with DNS verification fields. The provider verification route and `POST /_emulate/organization_domains/:id/verify` both mark one verified in a test.
- Vault KV: `POST /vault/v1/kv`, `GET /vault/v1/kv/name/:name`, `PUT /vault/v1/kv/:id`, `DELETE /vault/v1/kv/:id`.