Skip to content

Commit 34c198f

Browse files
WaylandYangclaude
andcommitted
Add WorkOS profile factory (Python + TypeScript)
Third big-vendor profile. WorkOS AuthKit issues standards-conformant OAuth OBO JWTs; AI agents acting on behalf of WorkOS users carry the agent identity in the act claim per RFC 8693. API: - Python: workos(client_id=..., audience=..., domain="api.workos.com") - TypeScript: workos({ clientId, audience, domain? }) Both accept an optional custom auth domain (for tenants that configured one) and default to api.workos.com otherwise. The JWKS URL pattern is the one cross-vendor quirk to record: even for AuthKit (User Management) tokens, WorkOS serves JWKS at /sso/jwks/<client_id> — NOT /user_management/jwks/<client_id>. Mixing those up gives a 404 at verification time. Both the Python and TS profiles construct the SSO-path URL correctly; the Python test sanity-checks this via monkeypatch. Tests added: 8 Python + 7 TypeScript covering construction, URL templating, custom domain support, input validation, and full end-to-end verification with a mocked JWKS for both languages. Test totals: - Python: 99 (was 91) - TypeScript: 71 (was 64) - Combined: 170 All green: pytest + ruff + mypy strict (Python) / tsc + vitest (TS). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 504e9ad commit 34c198f

8 files changed

Lines changed: 330 additions & 1 deletion

File tree

packages/demarche-py/src/demarche/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
StaticKeyProvider,
2121
auth0_ai_agents,
2222
entra_agent_id,
23+
workos,
2324
)
2425
from .audit import AuditEvent, AuditSink, LoggingSink
2526
from .errors import (
@@ -67,4 +68,5 @@
6768
# Profiles
6869
"auth0_ai_agents",
6970
"entra_agent_id",
71+
"workos",
7072
]

packages/demarche-py/src/demarche/adapters/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
OAuthOBOAdapter,
2020
StaticKeyProvider,
2121
)
22-
from .profiles import auth0_ai_agents, entra_agent_id
22+
from .profiles import auth0_ai_agents, entra_agent_id, workos
2323

2424
__all__ = [
2525
"JWKSKeyProvider",
@@ -28,4 +28,5 @@
2828
"StaticKeyProvider",
2929
"auth0_ai_agents",
3030
"entra_agent_id",
31+
"workos",
3132
]

packages/demarche-py/src/demarche/adapters/profiles.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,61 @@ def entra_agent_id(
6767
)
6868

6969

70+
def workos(
71+
*,
72+
client_id: str,
73+
audience: str,
74+
domain: str = "api.workos.com",
75+
cache_ttl_seconds: int = 3600,
76+
) -> OAuthOBOAdapter:
77+
"""Build an OAuthOBOAdapter for WorkOS AuthKit OAuth OBO tokens.
78+
79+
WorkOS AuthKit issues standards-conformant OAuth OBO JWTs. AI agents
80+
acting on behalf of a WorkOS user receive tokens with the actor
81+
identity in the ``act`` claim per RFC 8693.
82+
83+
The ``iss`` and JWKS URLs are formed from your WorkOS client ID.
84+
The JWKS lives at the ``/sso/jwks/<client_id>`` path even when the
85+
user lives in AuthKit (User Management) — this is a WorkOS quirk
86+
documented in their JWT-validation guide.
87+
88+
Args:
89+
client_id: Your WorkOS application client ID, e.g.
90+
``"client_01HXYZ..."``.
91+
audience: The API audience identifier registered for your
92+
application.
93+
domain: WorkOS API domain. Defaults to ``api.workos.com``.
94+
Override if you have a custom auth domain configured.
95+
cache_ttl_seconds: JWKS cache TTL.
96+
97+
Returns:
98+
A configured :class:`OAuthOBOAdapter`.
99+
100+
Example::
101+
102+
adapter = workos(
103+
client_id="client_01HXYZ1234567890",
104+
audience="https://api.myapp.com",
105+
)
106+
"""
107+
if not client_id:
108+
raise ValueError("client_id is required")
109+
if not domain:
110+
raise ValueError("domain must be non-empty")
111+
clean_domain = (
112+
domain.removeprefix("https://").removeprefix("http://").rstrip("/")
113+
)
114+
issuer = f"https://{clean_domain}/user_management/{client_id}"
115+
jwks_url = f"https://{clean_domain}/sso/jwks/{client_id}"
116+
return OAuthOBOAdapter(
117+
issuer=issuer,
118+
audience=audience,
119+
key_provider=JWKSKeyProvider(
120+
jwks_url, cache_ttl_seconds=cache_ttl_seconds
121+
),
122+
)
123+
124+
70125
def auth0_ai_agents(
71126
*,
72127
domain: str,

packages/demarche-py/tests/test_profiles.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
Verifier,
1919
auth0_ai_agents,
2020
entra_agent_id,
21+
workos,
2122
)
2223

2324
# ---------- Entra Agent ID profile -------------------------------------------
@@ -227,6 +228,115 @@ def patched_init(
227228
assert result.agent_id == "agent-bot42"
228229

229230

231+
# ---------- WorkOS profile ---------------------------------------------------
232+
233+
234+
def test_workos_returns_obo_adapter() -> None:
235+
adapter = workos(
236+
client_id="client_01HXYZ1234567890",
237+
audience="https://api.example.com",
238+
)
239+
assert isinstance(adapter, OAuthOBOAdapter)
240+
assert adapter.name == "oauth-obo"
241+
242+
243+
def test_workos_issuer_url_pattern() -> None:
244+
adapter = workos(client_id="client_01HXYZ", audience="aud")
245+
assert (
246+
adapter.expected_issuer
247+
== "https://api.workos.com/user_management/client_01HXYZ"
248+
)
249+
250+
251+
def test_workos_jwks_url_uses_sso_path_not_user_management() -> None:
252+
"""WorkOS quirk: JWKS lives under /sso/jwks/ even for AuthKit tokens."""
253+
adapter = workos(client_id="client_01HXYZ", audience="aud")
254+
# We can't directly read the URL without exposing internal state,
255+
# but the JWKSKeyProvider was constructed — verifying the type
256+
# is sufficient; the end-to-end test below proves the URL.
257+
assert isinstance(adapter.key_provider, JWKSKeyProvider)
258+
259+
260+
def test_workos_custom_domain() -> None:
261+
adapter = workos(
262+
client_id="client_x",
263+
audience="aud",
264+
domain="auth.example.com",
265+
)
266+
assert (
267+
adapter.expected_issuer
268+
== "https://auth.example.com/user_management/client_x"
269+
)
270+
271+
272+
def test_workos_strips_protocol_from_custom_domain() -> None:
273+
adapter = workos(
274+
client_id="client_x",
275+
audience="aud",
276+
domain="https://auth.example.com/",
277+
)
278+
assert (
279+
adapter.expected_issuer
280+
== "https://auth.example.com/user_management/client_x"
281+
)
282+
283+
284+
def test_workos_requires_client_id() -> None:
285+
with pytest.raises(ValueError, match="client_id"):
286+
workos(client_id="", audience="aud")
287+
288+
289+
def test_workos_requires_domain() -> None:
290+
with pytest.raises(ValueError, match="domain"):
291+
workos(client_id="client_x", audience="aud", domain="")
292+
293+
294+
async def test_workos_end_to_end_with_mocked_jwks(
295+
rsa_keypair: tuple[RSAPrivateKey, Any], monkeypatch: pytest.MonkeyPatch
296+
) -> None:
297+
"""A token issued under WorkOS's iss / aud / kid pattern verifies cleanly."""
298+
private_key, public_key = rsa_keypair
299+
client_id = "client_01HXYZ1234567890"
300+
301+
mock_client = _serve_jwks_via_mock(public_key, kid="workos-key-1")
302+
original_init = JWKSKeyProvider.__init__
303+
304+
def patched_init(
305+
self: JWKSKeyProvider, jwks_url: str, **kwargs: Any
306+
) -> None:
307+
# Sanity check: WorkOS JWKS URL must use the sso/jwks path,
308+
# not user_management/jwks.
309+
assert "/sso/jwks/" in jwks_url, jwks_url
310+
assert client_id in jwks_url
311+
kwargs["http_client"] = mock_client
312+
original_init(self, jwks_url, **kwargs)
313+
314+
monkeypatch.setattr(JWKSKeyProvider, "__init__", patched_init)
315+
316+
adapter = workos(client_id=client_id, audience="https://api.myapp.com")
317+
318+
now = int(time.time())
319+
token = jwt.encode(
320+
{
321+
"iss": f"https://api.workos.com/user_management/{client_id}",
322+
"aud": "https://api.myapp.com",
323+
"sub": "user_01HABC",
324+
"act": {"sub": "agent_workos_bot"},
325+
"iat": now,
326+
"exp": now + 3600,
327+
"scope": "read:user",
328+
},
329+
private_key,
330+
algorithm="RS256",
331+
headers={"kid": "workos-key-1"},
332+
)
333+
334+
verifier = Verifier(adapters=[adapter])
335+
result = await verifier.verify(token)
336+
assert result.principal_id == "user_01HABC"
337+
assert result.agent_id == "agent_workos_bot"
338+
339+
230340
async def test_auth0_rejects_token_missing_trailing_slash_in_iss(
231341
rsa_keypair: tuple[RSAPrivateKey, Any], monkeypatch: pytest.MonkeyPatch
232342
) -> None:

packages/demarche-ts/src/adapters/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ export {
1515
type Auth0AiAgentsOptions,
1616
entraAgentId,
1717
type EntraAgentIdOptions,
18+
workos,
19+
type WorkOSOptions,
1820
} from "./profiles.js";

packages/demarche-ts/src/adapters/profiles.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,71 @@ export function entraAgentId(options: EntraAgentIdOptions): OAuthOBOAdapter {
7070
});
7171
}
7272

73+
export interface WorkOSOptions {
74+
/**
75+
* Your WorkOS application client ID, e.g. `"client_01HXYZ..."`.
76+
*/
77+
clientId: string;
78+
/**
79+
* The API audience identifier registered for your application.
80+
*/
81+
audience: string;
82+
/**
83+
* WorkOS API domain. Defaults to `api.workos.com`. Override if you
84+
* have a custom auth domain configured.
85+
*/
86+
domain?: string;
87+
/** JWKS cache TTL. Default: 3600 seconds. */
88+
cacheTtlSeconds?: number;
89+
/** Optional fetch override (testing / connection pooling). */
90+
fetch?: typeof globalThis.fetch;
91+
}
92+
93+
/**
94+
* Build an OAuthOBOAdapter for WorkOS AuthKit OAuth OBO tokens.
95+
*
96+
* WorkOS AuthKit issues standards-conformant OAuth OBO JWTs. AI agents
97+
* acting on behalf of a WorkOS user receive tokens with the actor
98+
* identity in the `act` claim per RFC 8693.
99+
*
100+
* The `iss` and JWKS URLs are formed from your WorkOS client ID. The
101+
* JWKS lives at the `/sso/jwks/<client_id>` path even when the user
102+
* lives in AuthKit (User Management) — this is a WorkOS quirk
103+
* documented in their JWT-validation guide.
104+
*
105+
* @example
106+
* const adapter = workos({
107+
* clientId: "client_01HXYZ1234567890",
108+
* audience: "https://api.myapp.com",
109+
* });
110+
*/
111+
export function workos(options: WorkOSOptions): OAuthOBOAdapter {
112+
if (!options.clientId) {
113+
throw new Error("clientId is required");
114+
}
115+
const rawDomain = options.domain ?? "api.workos.com";
116+
if (!rawDomain) {
117+
throw new Error("domain must be non-empty");
118+
}
119+
const cleanDomain = rawDomain
120+
.replace(/^https?:\/\//i, "")
121+
.replace(/\/+$/, "");
122+
const issuer = `https://${cleanDomain}/user_management/${options.clientId}`;
123+
const jwksUrl = `https://${cleanDomain}/sso/jwks/${options.clientId}`;
124+
const jwksOptions: JWKSKeyProviderOptions = {};
125+
if (options.cacheTtlSeconds !== undefined) {
126+
jwksOptions.cacheTtlSeconds = options.cacheTtlSeconds;
127+
}
128+
if (options.fetch !== undefined) {
129+
jwksOptions.fetch = options.fetch;
130+
}
131+
return new OAuthOBOAdapter({
132+
issuer,
133+
audience: options.audience,
134+
keyProvider: new JWKSKeyProvider(jwksUrl, jwksOptions),
135+
});
136+
}
137+
73138
export interface Auth0AiAgentsOptions {
74139
/**
75140
* Your Auth0 tenant domain, e.g. `"myapp.us.auth0.com"`. Accepts

packages/demarche-ts/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export {
2424
OAuthOBOAdapter,
2525
type OAuthOBOAdapterOptions,
2626
StaticKeyProvider,
27+
workos,
28+
type WorkOSOptions,
2729
} from "./adapters/index.js";
2830
export {
2931
AuditEvent,

0 commit comments

Comments
 (0)