Skip to content
Draft
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
78 changes: 78 additions & 0 deletions packages/@emulators/workos/src/__tests__/workos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

});
7 changes: 7 additions & 0 deletions packages/@emulators/workos/src/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}
26 changes: 21 additions & 5 deletions packages/@emulators/workos/src/routes/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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);
Expand All @@ -113,6 +117,7 @@ export function oauthRoutes(ctx: RouteContext): void {
state,
code_challenge: codeChallenge,
scope,
resource,
},
}),
)
Expand All @@ -124,6 +129,7 @@ export function oauthRoutes(ctx: RouteContext): void {
<input type="hidden" name="state" value="${escapeHtml(state)}" />
<input type="hidden" name="code_challenge" value="${escapeHtml(codeChallenge)}" />
<input type="hidden" name="scope" value="${escapeHtml(scope)}" />
<input type="hidden" name="resource" value="${escapeHtml(resource)}" />
<input type="email" name="email" class="checkout-input" placeholder="new-user@example.com" required />
<button type="submit" class="checkout-pay-btn">Continue as new user</button>
</form>`;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion skills/workos/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Loading