Skip to content

Commit d703eb1

Browse files
rustyconoverclaude
andcommitted
Add client_secret to OAuth Resource Metadata and reduce cache max-age to 60s
Google requires client_secret in OAuth resource metadata even when using PKCE. Added following the same pattern as client_id. Reduced .well-known cache max-age from 3600 to 60 seconds. Bump version to 0.1.20. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ec6cb0f commit d703eb1

7 files changed

Lines changed: 155 additions & 9 deletions

File tree

docs/api/oauth.md

Lines changed: 21 additions & 5 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="..."` (and optionally `client_id="..."`)
50+
2. 401 responses include `WWW-Authenticate: Bearer resource_metadata="..."` (and optionally `client_id="..."`, `client_secret="..."`)
5151
3. Client fetches metadata to discover authorization server(s)
5252
4. Client authenticates with the AS and sends Bearer token
5353

@@ -57,21 +57,23 @@ 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, parse_client_id, fetch_oauth_metadata
60+
from vgi_rpc.http import parse_resource_metadata_url, parse_client_id, parse_client_secret, 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 and optional client_id from WWW-Authenticate header
65+
# 2. Parse the metadata URL, optional client_id and client_secret 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"
6969
client_id = parse_client_id(www_auth) # e.g. "my-app" or None
70+
client_secret = parse_client_secret(www_auth) # e.g. "my-secret" or None
7071

7172
# 3. Fetch the metadata
7273
meta = fetch_oauth_metadata(metadata_url)
7374
print(meta.authorization_servers) # use these to authenticate
7475
print(meta.client_id) # also available from the metadata document
76+
print(meta.client_secret) # also available from the metadata document
7577
```
7678

7779
## OAuthResourceMetadata
@@ -91,6 +93,7 @@ Pass to `make_wsgi_app(oauth_resource_metadata=...)` to enable OAuth discovery.
9193
| `resource_policy_uri` | `str \| None` | No | URL to privacy policy |
9294
| `resource_tos_uri` | `str \| None` | No | URL to terms of service |
9395
| `client_id` | `str \| None` | No | OAuth client_id for auth server *(custom extension, not in RFC 9728)* |
96+
| `client_secret` | `str \| None` | No | OAuth client_secret for auth server *(custom extension, not in RFC 9728)*. Intended for public/PKCE clients (e.g. Google OAuth) where the secret is not truly confidential. |
9497

9598
Raises `ValueError` if `resource` is empty or `authorization_servers` is empty.
9699

@@ -287,18 +290,31 @@ client_id = parse_client_id('Bearer resource_metadata="https://...", client_id="
287290

288291
Returns `None` if the header doesn't contain `client_id`.
289292

293+
## parse_client_secret()
294+
295+
Extract the `client_secret` from a `WWW-Authenticate` header. Custom extension (not in RFC 9728).
296+
297+
```python
298+
from vgi_rpc.http import parse_client_secret
299+
300+
client_secret = parse_client_secret('Bearer resource_metadata="https://...", client_secret="my-secret"')
301+
# "my-secret"
302+
```
303+
304+
Returns `None` if the header doesn't contain `client_secret`.
305+
290306
## OAuthResourceMetadataResponse
291307

292308
Frozen dataclass returned by `http_oauth_metadata()` and `fetch_oauth_metadata()`.
293-
Same fields as `OAuthResourceMetadata` (the server-side config class), including `client_id`.
309+
Same fields as `OAuthResourceMetadata` (the server-side config class), including `client_id` and `client_secret`.
294310

295311
## Standards Compliance
296312

297313
- [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) — OAuth 2.0 Protected Resource Metadata
298314
- [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) — OAuth 2.0 Authorization Server Metadata
299315
- [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) — Bearer Token Usage
300316
- 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
317+
- **Custom extensions**: `client_id` and `client_secret` fields on `OAuthResourceMetadata` / `OAuthResourceMetadataResponse` and in `WWW-Authenticate` headers are not defined in RFC 9728
302318

303319
## Installation
304320

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.19"
3+
version = "0.1.20"
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: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
jwt_authenticate,
2727
make_sync_client,
2828
parse_client_id,
29+
parse_client_secret,
2930
parse_resource_metadata_url,
3031
)
3132

@@ -123,6 +124,9 @@ def authenticate(req: falcon.Request) -> AuthContext:
123124
)
124125

125126
_METADATA_WITH_CLIENT_ID = dataclasses.replace(_METADATA, client_id="my-client-id")
127+
_METADATA_WITH_CLIENT_SECRET = dataclasses.replace(
128+
_METADATA, client_id="my-client-id", client_secret="my-client-secret"
129+
)
126130

127131

128132
# ---------------------------------------------------------------------------
@@ -188,6 +192,7 @@ def test_well_known_omits_default_fields(self) -> None:
188192
assert "bearer_methods_supported" not in d
189193
assert "resource_name" not in d
190194
assert "client_id" not in d
195+
assert "client_secret" not in d
191196

192197
def test_well_known_exempt_from_auth(self) -> None:
193198
"""Well-known endpoint is accessible even with auth enabled."""
@@ -271,7 +276,7 @@ def test_cache_control_header(self) -> None:
271276
resp = client.get("/.well-known/oauth-protected-resource")
272277
assert resp.status_code == 200
273278
cache = resp.headers.get("cache-control", "")
274-
assert "max-age" in cache
279+
assert "max-age=60" in cache
275280

276281
def test_backwards_compatible(self) -> None:
277282
"""Server without oauth_resource_metadata still works normally."""
@@ -377,6 +382,91 @@ def test_client_discovery_round_trip_with_client_id(self) -> None:
377382
assert meta is not None
378383
assert meta.client_id == "my-client-id"
379384

385+
def test_client_secret_rejects_unsafe_characters(self) -> None:
386+
"""client_secret with non-URL-safe characters raises ValueError."""
387+
with pytest.raises(ValueError, match="URL-safe"):
388+
OAuthResourceMetadata(
389+
resource="https://example.com/vgi",
390+
authorization_servers=("https://auth.example.com",),
391+
client_secret='bad"secret',
392+
)
393+
with pytest.raises(ValueError, match="URL-safe"):
394+
OAuthResourceMetadata(
395+
resource="https://example.com/vgi",
396+
authorization_servers=("https://auth.example.com",),
397+
client_secret="has space",
398+
)
399+
400+
def test_client_secret_in_well_known_json(self) -> None:
401+
"""client_secret appears in well-known JSON when set."""
402+
server = RpcServer(_EchoService, _EchoImpl())
403+
client = make_sync_client(server, signing_key=b"k", oauth_resource_metadata=_METADATA_WITH_CLIENT_SECRET)
404+
resp = client.get("/.well-known/oauth-protected-resource")
405+
body = json.loads(resp.content)
406+
assert body["client_secret"] == "my-client-secret"
407+
408+
def test_client_secret_in_www_authenticate(self) -> None:
409+
"""client_secret appears in WWW-Authenticate header when metadata has client_secret."""
410+
_priv, pub = _make_rsa_key()
411+
auth_fn = _make_local_auth(pub)
412+
server = RpcServer(_EchoService, _EchoImpl())
413+
client = make_sync_client(
414+
server,
415+
signing_key=b"k",
416+
authenticate=auth_fn,
417+
oauth_resource_metadata=_METADATA_WITH_CLIENT_SECRET,
418+
)
419+
resp = client.post(
420+
"/vgi/echo",
421+
content=b"garbage",
422+
headers={"Content-Type": "application/octet-stream"},
423+
)
424+
assert resp.status_code == 401
425+
www_auth = resp.headers.get("www-authenticate", "")
426+
assert 'client_secret="my-client-secret"' in www_auth
427+
428+
def test_client_secret_absent_from_www_authenticate(self) -> None:
429+
"""client_secret absent from WWW-Authenticate when metadata has no client_secret."""
430+
_priv, pub = _make_rsa_key()
431+
auth_fn = _make_local_auth(pub)
432+
server = RpcServer(_EchoService, _EchoImpl())
433+
client = make_sync_client(
434+
server,
435+
signing_key=b"k",
436+
authenticate=auth_fn,
437+
oauth_resource_metadata=_METADATA,
438+
)
439+
resp = client.post(
440+
"/vgi/echo",
441+
content=b"garbage",
442+
headers={"Content-Type": "application/octet-stream"},
443+
)
444+
assert resp.status_code == 401
445+
www_auth = resp.headers.get("www-authenticate", "")
446+
assert "client_secret" not in www_auth
447+
448+
def test_parse_client_secret_extracts_value(self) -> None:
449+
"""parse_client_secret() extracts value from header."""
450+
header = (
451+
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/vgi"'
452+
', client_id="my-app", client_secret="my-secret"'
453+
)
454+
assert parse_client_secret(header) == "my-secret"
455+
456+
def test_parse_client_secret_returns_none_when_absent(self) -> None:
457+
"""parse_client_secret() returns None when not present."""
458+
assert parse_client_secret("Bearer") is None
459+
assert parse_client_secret('Bearer resource_metadata="https://example.com"') is None
460+
assert parse_client_secret("") is None
461+
462+
def test_client_discovery_round_trip_with_client_secret(self) -> None:
463+
"""Client discovers client_secret set on server."""
464+
server = RpcServer(_EchoService, _EchoImpl())
465+
client = make_sync_client(server, signing_key=b"k", oauth_resource_metadata=_METADATA_WITH_CLIENT_SECRET)
466+
meta = http_oauth_metadata(client=client)
467+
assert meta is not None
468+
assert meta.client_secret == "my-client-secret"
469+
380470
def test_401_discovery_flow(self) -> None:
381471
"""Full 401-based discovery: get 401, parse header, fetch metadata."""
382472
_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
@@ -37,6 +37,7 @@
3737
http_introspect,
3838
http_oauth_metadata,
3939
parse_client_id,
40+
parse_client_secret,
4041
parse_resource_metadata_url,
4142
request_upload_urls,
4243
)
@@ -91,6 +92,7 @@
9192
"http_oauth_metadata",
9293
"make_sync_client",
9394
"parse_client_id",
95+
"parse_client_secret",
9496
"parse_resource_metadata_url",
9597
"make_wsgi_app",
9698
"serve_http",

vgi_rpc/http/_client.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,8 @@ class OAuthResourceMetadataResponse:
950950
resource_tos_uri: URL to the resource's terms of service.
951951
client_id: OAuth client_id to use when authenticating with the
952952
authorization server. Custom extension (not in RFC 9728).
953+
client_secret: OAuth client_secret to use when authenticating with the
954+
authorization server. Custom extension (not in RFC 9728).
953955
954956
"""
955957

@@ -963,6 +965,7 @@ class OAuthResourceMetadataResponse:
963965
resource_policy_uri: str | None = None
964966
resource_tos_uri: str | None = None
965967
client_id: str | None = None
968+
client_secret: str | None = None
966969

967970

968971
def http_oauth_metadata(
@@ -1015,6 +1018,7 @@ def http_oauth_metadata(
10151018

10161019
_RESOURCE_METADATA_RE = re.compile(r'resource_metadata="([^"]+)"')
10171020
_CLIENT_ID_RE = re.compile(r'client_id="([^"]+)"')
1021+
_CLIENT_SECRET_RE = re.compile(r'client_secret="([^"]+)"')
10181022

10191023

10201024
def parse_resource_metadata_url(www_authenticate: str) -> str | None:
@@ -1054,6 +1058,24 @@ def parse_client_id(www_authenticate: str) -> str | None:
10541058
return match.group(1) if match else None
10551059

10561060

1061+
def parse_client_secret(www_authenticate: str) -> str | None:
1062+
"""Extract the ``client_secret`` from a ``WWW-Authenticate`` header.
1063+
1064+
Parses a ``Bearer`` challenge and returns the ``client_secret`` parameter
1065+
value, or ``None`` if not present. This is a custom extension (not
1066+
defined in RFC 9728).
1067+
1068+
Args:
1069+
www_authenticate: The ``WWW-Authenticate`` header value.
1070+
1071+
Returns:
1072+
The client_secret string, or ``None`` if not present.
1073+
1074+
"""
1075+
match = _CLIENT_SECRET_RE.search(www_authenticate)
1076+
return match.group(1) if match else None
1077+
1078+
10571079
def _parse_metadata_json(body: dict[str, Any]) -> OAuthResourceMetadataResponse:
10581080
"""Parse a JSON dict into an ``OAuthResourceMetadataResponse``.
10591081
@@ -1078,6 +1100,7 @@ def _parse_metadata_json(body: dict[str, Any]) -> OAuthResourceMetadataResponse:
10781100
resource_policy_uri=body.get("resource_policy_uri"),
10791101
resource_tos_uri=body.get("resource_tos_uri"),
10801102
client_id=body.get("client_id"),
1103+
client_secret=body.get("client_secret"),
10811104
)
10821105

10831106

vgi_rpc/http/_oauth.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ class OAuthResourceMetadata:
4545
client_id: OAuth client_id that clients should use when
4646
authenticating with the authorization server. Custom
4747
extension (not defined in RFC 9728).
48+
client_secret: OAuth client_secret that clients should use when
49+
authenticating with the authorization server. Custom
50+
extension (not defined in RFC 9728).
4851
4952
Raises:
5053
ValueError: If *resource* is empty or *authorization_servers* is empty.
@@ -61,6 +64,7 @@ class OAuthResourceMetadata:
6164
resource_policy_uri: str | None = None
6265
resource_tos_uri: str | None = None
6366
client_id: str | None = None
67+
client_secret: str | None = None
6468

6569
def __post_init__(self) -> None:
6670
"""Validate required fields."""
@@ -73,6 +77,11 @@ def __post_init__(self) -> None:
7377
"OAuthResourceMetadata.client_id must contain only URL-safe characters "
7478
"(alphanumeric, hyphen, underscore, period, tilde)"
7579
)
80+
if self.client_secret is not None and not _URL_SAFE_RE.fullmatch(self.client_secret):
81+
raise ValueError(
82+
"OAuthResourceMetadata.client_secret must contain only URL-safe characters "
83+
"(alphanumeric, hyphen, underscore, period, tilde)"
84+
)
7685

7786
def to_json_dict(self) -> dict[str, object]:
7887
"""Serialize to a JSON-compatible dict per RFC 9728.
@@ -103,6 +112,8 @@ def to_json_dict(self) -> dict[str, object]:
103112
d["resource_tos_uri"] = self.resource_tos_uri
104113
if self.client_id is not None:
105114
d["client_id"] = self.client_id
115+
if self.client_secret is not None:
116+
d["client_secret"] = self.client_secret
106117
return d
107118

108119

@@ -124,7 +135,7 @@ def on_get(self, req: falcon.Request, resp: falcon.Response) -> None:
124135
"""
125136
resp.content_type = falcon.MEDIA_JSON
126137
resp.data = self._body
127-
resp.set_header("Cache-Control", "public, max-age=3600")
138+
resp.set_header("Cache-Control", "public, max-age=60")
128139

129140

130141
def _build_www_authenticate(metadata: OAuthResourceMetadata, prefix: str = "/vgi") -> str:
@@ -149,4 +160,8 @@ def _build_www_authenticate(metadata: OAuthResourceMetadata, prefix: str = "/vgi
149160
challenge = f'Bearer resource_metadata="{well_known_url}"'
150161
if metadata.client_id is not None:
151162
challenge += f', client_id="{metadata.client_id}"'
163+
# client_secret in the header is intentional: Google's PKCE flow treats it
164+
# as a "public" secret for native/SPA apps, not a truly confidential value.
165+
if metadata.client_secret is not None:
166+
challenge += f', client_secret="{metadata.client_secret}"'
152167
return challenge

0 commit comments

Comments
 (0)