Skip to content

Commit ec6cb0f

Browse files
rustyconoverclaude
andcommitted
Add client_id to OAuth Resource Metadata and bump version to 0.1.19
Add optional client_id field to OAuthResourceMetadata (server) and OAuthResourceMetadataResponse (client) as a custom RFC 9728 extension for MCP compatibility. Includes WWW-Authenticate header support, parse_client_id() helper, URL-safe validation, and documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b634dce commit ec6cb0f

7 files changed

Lines changed: 156 additions & 7 deletions

File tree

docs/api/oauth.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ with http_connect(MyService, "https://api.example.com") as svc:
4747
## How It Works
4848

4949
1. Server serves `/.well-known/oauth-protected-resource` (RFC 9728)
50-
2. 401 responses include `WWW-Authenticate: Bearer resource_metadata="..."`
50+
2. 401 responses include `WWW-Authenticate: Bearer resource_metadata="..."` (and optionally `client_id="..."`)
5151
3. Client fetches metadata to discover authorization server(s)
5252
4. Client authenticates with the AS and sends Bearer token
5353

@@ -57,19 +57,21 @@ If a client doesn't know the server's auth requirements upfront, it can
5757
discover them from a 401 response:
5858

5959
```python
60-
from vgi_rpc.http import parse_resource_metadata_url, fetch_oauth_metadata
60+
from vgi_rpc.http import parse_resource_metadata_url, parse_client_id, fetch_oauth_metadata
6161

6262
# 1. Make a request that returns 401
6363
resp = client.post("/vgi/my_method", ...)
6464

65-
# 2. Parse the metadata URL from WWW-Authenticate header
65+
# 2. Parse the metadata URL and optional client_id from WWW-Authenticate header
6666
www_auth = resp.headers["www-authenticate"]
6767
metadata_url = parse_resource_metadata_url(www_auth)
6868
# "https://api.example.com/.well-known/oauth-protected-resource/vgi"
69+
client_id = parse_client_id(www_auth) # e.g. "my-app" or None
6970

7071
# 3. Fetch the metadata
7172
meta = fetch_oauth_metadata(metadata_url)
7273
print(meta.authorization_servers) # use these to authenticate
74+
print(meta.client_id) # also available from the metadata document
7375
```
7476

7577
## OAuthResourceMetadata
@@ -88,6 +90,7 @@ Pass to `make_wsgi_app(oauth_resource_metadata=...)` to enable OAuth discovery.
8890
| `resource_documentation` | `str \| None` | No | URL to developer docs |
8991
| `resource_policy_uri` | `str \| None` | No | URL to privacy policy |
9092
| `resource_tos_uri` | `str \| None` | No | URL to terms of service |
93+
| `client_id` | `str \| None` | No | OAuth client_id for auth server *(custom extension, not in RFC 9728)* |
9194

9295
Raises `ValueError` if `resource` is empty or `authorization_servers` is empty.
9396

@@ -271,17 +274,31 @@ url = parse_resource_metadata_url('Bearer resource_metadata="https://..."')
271274

272275
Returns `None` if the header doesn't contain `resource_metadata`.
273276

277+
## parse_client_id()
278+
279+
Extract the `client_id` from a `WWW-Authenticate` header. Custom extension (not in RFC 9728).
280+
281+
```python
282+
from vgi_rpc.http import parse_client_id
283+
284+
client_id = parse_client_id('Bearer resource_metadata="https://...", client_id="my-app"')
285+
# "my-app"
286+
```
287+
288+
Returns `None` if the header doesn't contain `client_id`.
289+
274290
## OAuthResourceMetadataResponse
275291

276292
Frozen dataclass returned by `http_oauth_metadata()` and `fetch_oauth_metadata()`.
277-
Same fields as `OAuthResourceMetadata` (the server-side config class).
293+
Same fields as `OAuthResourceMetadata` (the server-side config class), including `client_id`.
278294

279295
## Standards Compliance
280296

281297
- [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) — OAuth 2.0 Protected Resource Metadata
282298
- [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) — OAuth 2.0 Authorization Server Metadata
283299
- [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) — Bearer Token Usage
284300
- Compatible with MCP's OAuth implementation
301+
- **Custom extension**: `client_id` field on `OAuthResourceMetadata` / `OAuthResourceMetadataResponse` and in `WWW-Authenticate` headers is not defined in RFC 9728
285302

286303
## Installation
287304

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "vgi-rpc"
3-
version = "0.1.18"
3+
version = "0.1.19"
44
description = "Vector Gateway Interface - RPC framework based on Apache Arrow"
55
readme = "README.md"
66
requires-python = ">=3.13"

tests/test_oauth.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import dataclasses
89
import json
910
import time
1011
from collections.abc import Callable
@@ -24,6 +25,7 @@
2425
http_oauth_metadata,
2526
jwt_authenticate,
2627
make_sync_client,
28+
parse_client_id,
2729
parse_resource_metadata_url,
2830
)
2931

@@ -120,6 +122,8 @@ def authenticate(req: falcon.Request) -> AuthContext:
120122
resource_name="Test Service",
121123
)
122124

125+
_METADATA_WITH_CLIENT_ID = dataclasses.replace(_METADATA, client_id="my-client-id")
126+
123127

124128
# ---------------------------------------------------------------------------
125129
# TestOAuthResourceMetadata
@@ -183,6 +187,7 @@ def test_well_known_omits_default_fields(self) -> None:
183187
assert "scopes_supported" not in d
184188
assert "bearer_methods_supported" not in d
185189
assert "resource_name" not in d
190+
assert "client_id" not in d
186191

187192
def test_well_known_exempt_from_auth(self) -> None:
188193
"""Well-known endpoint is accessible even with auth enabled."""
@@ -287,6 +292,91 @@ def test_parse_resource_metadata_url_missing(self) -> None:
287292
assert parse_resource_metadata_url("Basic realm=test") is None
288293
assert parse_resource_metadata_url("") is None
289294

295+
def test_client_id_rejects_unsafe_characters(self) -> None:
296+
"""client_id with non-URL-safe characters raises ValueError."""
297+
with pytest.raises(ValueError, match="URL-safe"):
298+
OAuthResourceMetadata(
299+
resource="https://example.com/vgi",
300+
authorization_servers=("https://auth.example.com",),
301+
client_id='bad"id',
302+
)
303+
with pytest.raises(ValueError, match="URL-safe"):
304+
OAuthResourceMetadata(
305+
resource="https://example.com/vgi",
306+
authorization_servers=("https://auth.example.com",),
307+
client_id="has space",
308+
)
309+
310+
def test_client_id_in_well_known_json(self) -> None:
311+
"""client_id appears in well-known JSON when set."""
312+
server = RpcServer(_EchoService, _EchoImpl())
313+
client = make_sync_client(server, signing_key=b"k", oauth_resource_metadata=_METADATA_WITH_CLIENT_ID)
314+
resp = client.get("/.well-known/oauth-protected-resource")
315+
body = json.loads(resp.content)
316+
assert body["client_id"] == "my-client-id"
317+
318+
def test_client_id_in_www_authenticate(self) -> None:
319+
"""client_id appears in WWW-Authenticate header when metadata has client_id."""
320+
_priv, pub = _make_rsa_key()
321+
auth_fn = _make_local_auth(pub)
322+
server = RpcServer(_EchoService, _EchoImpl())
323+
client = make_sync_client(
324+
server,
325+
signing_key=b"k",
326+
authenticate=auth_fn,
327+
oauth_resource_metadata=_METADATA_WITH_CLIENT_ID,
328+
)
329+
resp = client.post(
330+
"/vgi/echo",
331+
content=b"garbage",
332+
headers={"Content-Type": "application/octet-stream"},
333+
)
334+
assert resp.status_code == 401
335+
www_auth = resp.headers.get("www-authenticate", "")
336+
assert 'client_id="my-client-id"' in www_auth
337+
338+
def test_client_id_absent_from_www_authenticate(self) -> None:
339+
"""client_id absent from WWW-Authenticate when metadata has no client_id."""
340+
_priv, pub = _make_rsa_key()
341+
auth_fn = _make_local_auth(pub)
342+
server = RpcServer(_EchoService, _EchoImpl())
343+
client = make_sync_client(
344+
server,
345+
signing_key=b"k",
346+
authenticate=auth_fn,
347+
oauth_resource_metadata=_METADATA,
348+
)
349+
resp = client.post(
350+
"/vgi/echo",
351+
content=b"garbage",
352+
headers={"Content-Type": "application/octet-stream"},
353+
)
354+
assert resp.status_code == 401
355+
www_auth = resp.headers.get("www-authenticate", "")
356+
assert "client_id" not in www_auth
357+
358+
def test_parse_client_id_extracts_value(self) -> None:
359+
"""parse_client_id() extracts value from header."""
360+
header = (
361+
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/vgi"'
362+
', client_id="my-app"'
363+
)
364+
assert parse_client_id(header) == "my-app"
365+
366+
def test_parse_client_id_returns_none_when_absent(self) -> None:
367+
"""parse_client_id() returns None when not present."""
368+
assert parse_client_id("Bearer") is None
369+
assert parse_client_id('Bearer resource_metadata="https://example.com"') is None
370+
assert parse_client_id("") is None
371+
372+
def test_client_discovery_round_trip_with_client_id(self) -> None:
373+
"""Client discovers client_id set on server."""
374+
server = RpcServer(_EchoService, _EchoImpl())
375+
client = make_sync_client(server, signing_key=b"k", oauth_resource_metadata=_METADATA_WITH_CLIENT_ID)
376+
meta = http_oauth_metadata(client=client)
377+
assert meta is not None
378+
assert meta.client_id == "my-client-id"
379+
290380
def test_401_discovery_flow(self) -> None:
291381
"""Full 401-based discovery: get 401, parse header, fetch metadata."""
292382
_priv, pub = _make_rsa_key()

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vgi_rpc/http/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
http_connect,
3737
http_introspect,
3838
http_oauth_metadata,
39+
parse_client_id,
3940
parse_resource_metadata_url,
4041
request_upload_urls,
4142
)
@@ -89,6 +90,7 @@
8990
"http_introspect",
9091
"http_oauth_metadata",
9192
"make_sync_client",
93+
"parse_client_id",
9294
"parse_resource_metadata_url",
9395
"make_wsgi_app",
9496
"serve_http",

vgi_rpc/http/_client.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,8 @@ class OAuthResourceMetadataResponse:
948948
resource_documentation: URL to developer documentation.
949949
resource_policy_uri: URL to the resource's privacy policy.
950950
resource_tos_uri: URL to the resource's terms of service.
951+
client_id: OAuth client_id to use when authenticating with the
952+
authorization server. Custom extension (not in RFC 9728).
951953
952954
"""
953955

@@ -960,6 +962,7 @@ class OAuthResourceMetadataResponse:
960962
resource_documentation: str | None = None
961963
resource_policy_uri: str | None = None
962964
resource_tos_uri: str | None = None
965+
client_id: str | None = None
963966

964967

965968
def http_oauth_metadata(
@@ -1011,6 +1014,7 @@ def http_oauth_metadata(
10111014

10121015

10131016
_RESOURCE_METADATA_RE = re.compile(r'resource_metadata="([^"]+)"')
1017+
_CLIENT_ID_RE = re.compile(r'client_id="([^"]+)"')
10141018

10151019

10161020
def parse_resource_metadata_url(www_authenticate: str) -> str | None:
@@ -1032,6 +1036,24 @@ def parse_resource_metadata_url(www_authenticate: str) -> str | None:
10321036
return match.group(1) if match else None
10331037

10341038

1039+
def parse_client_id(www_authenticate: str) -> str | None:
1040+
"""Extract the ``client_id`` from a ``WWW-Authenticate`` header.
1041+
1042+
Parses a ``Bearer`` challenge and returns the ``client_id`` parameter
1043+
value, or ``None`` if not present. This is a custom extension (not
1044+
defined in RFC 9728).
1045+
1046+
Args:
1047+
www_authenticate: The ``WWW-Authenticate`` header value.
1048+
1049+
Returns:
1050+
The client_id string, or ``None`` if not present.
1051+
1052+
"""
1053+
match = _CLIENT_ID_RE.search(www_authenticate)
1054+
return match.group(1) if match else None
1055+
1056+
10351057
def _parse_metadata_json(body: dict[str, Any]) -> OAuthResourceMetadataResponse:
10361058
"""Parse a JSON dict into an ``OAuthResourceMetadataResponse``.
10371059
@@ -1055,6 +1077,7 @@ def _parse_metadata_json(body: dict[str, Any]) -> OAuthResourceMetadataResponse:
10551077
resource_documentation=body.get("resource_documentation"),
10561078
resource_policy_uri=body.get("resource_policy_uri"),
10571079
resource_tos_uri=body.get("resource_tos_uri"),
1080+
client_id=body.get("client_id"),
10581081
)
10591082

10601083

vgi_rpc/http/_oauth.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@
1313
from __future__ import annotations
1414

1515
import json
16+
import re
1617
from dataclasses import dataclass
1718
from urllib.parse import urlparse
1819

1920
import falcon
2021

22+
_URL_SAFE_RE = re.compile(r"[A-Za-z0-9\-._~]+")
23+
2124

2225
@dataclass(frozen=True)
2326
class OAuthResourceMetadata:
@@ -39,6 +42,9 @@ class OAuthResourceMetadata:
3942
resource_documentation: URL to developer documentation.
4043
resource_policy_uri: URL to the resource's privacy policy.
4144
resource_tos_uri: URL to the resource's terms of service.
45+
client_id: OAuth client_id that clients should use when
46+
authenticating with the authorization server. Custom
47+
extension (not defined in RFC 9728).
4248
4349
Raises:
4450
ValueError: If *resource* is empty or *authorization_servers* is empty.
@@ -54,13 +60,19 @@ class OAuthResourceMetadata:
5460
resource_documentation: str | None = None
5561
resource_policy_uri: str | None = None
5662
resource_tos_uri: str | None = None
63+
client_id: str | None = None
5764

5865
def __post_init__(self) -> None:
5966
"""Validate required fields."""
6067
if not self.resource:
6168
raise ValueError("OAuthResourceMetadata.resource must not be empty")
6269
if not self.authorization_servers:
6370
raise ValueError("OAuthResourceMetadata.authorization_servers must contain at least one entry")
71+
if self.client_id is not None and not _URL_SAFE_RE.fullmatch(self.client_id):
72+
raise ValueError(
73+
"OAuthResourceMetadata.client_id must contain only URL-safe characters "
74+
"(alphanumeric, hyphen, underscore, period, tilde)"
75+
)
6476

6577
def to_json_dict(self) -> dict[str, object]:
6678
"""Serialize to a JSON-compatible dict per RFC 9728.
@@ -89,6 +101,8 @@ def to_json_dict(self) -> dict[str, object]:
89101
d["resource_policy_uri"] = self.resource_policy_uri
90102
if self.resource_tos_uri is not None:
91103
d["resource_tos_uri"] = self.resource_tos_uri
104+
if self.client_id is not None:
105+
d["client_id"] = self.client_id
92106
return d
93107

94108

@@ -132,4 +146,7 @@ def _build_www_authenticate(metadata: OAuthResourceMetadata, prefix: str = "/vgi
132146
parsed = urlparse(metadata.resource)
133147
path_suffix = prefix if prefix != "/" else ""
134148
well_known_url = f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{path_suffix}"
135-
return f'Bearer resource_metadata="{well_known_url}"'
149+
challenge = f'Bearer resource_metadata="{well_known_url}"'
150+
if metadata.client_id is not None:
151+
challenge += f', client_id="{metadata.client_id}"'
152+
return challenge

0 commit comments

Comments
 (0)