Skip to content

Commit b4e5459

Browse files
rustyconoverclaude
andcommitted
Add use_id_token_as_bearer to OAuth Resource Metadata and bump version to 0.1.21
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d703eb1 commit b4e5459

6 files changed

Lines changed: 133 additions & 6 deletions

File tree

docs/api/oauth.md

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

@@ -57,17 +57,18 @@ 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, parse_client_secret, fetch_oauth_metadata
60+
from vgi_rpc.http import parse_resource_metadata_url, parse_client_id, parse_client_secret, parse_use_id_token_as_bearer, 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, optional client_id and client_secret from WWW-Authenticate header
65+
# 2. Parse the metadata URL, optional client_id, client_secret, and use_id_token_as_bearer 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
7070
client_secret = parse_client_secret(www_auth) # e.g. "my-secret" or None
71+
use_id_token = parse_use_id_token_as_bearer(www_auth) # True or False
7172

7273
# 3. Fetch the metadata
7374
meta = fetch_oauth_metadata(metadata_url)
@@ -94,6 +95,7 @@ Pass to `make_wsgi_app(oauth_resource_metadata=...)` to enable OAuth discovery.
9495
| `resource_tos_uri` | `str \| None` | No | URL to terms of service |
9596
| `client_id` | `str \| None` | No | OAuth client_id for auth server *(custom extension, not in RFC 9728)* |
9697
| `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. |
98+
| `use_id_token_as_bearer` | `bool` | No | When `True`, clients should use the OIDC `id_token` as the Bearer token instead of `access_token` *(custom extension, not in RFC 9728)* |
9799

98100
Raises `ValueError` if `resource` is empty or `authorization_servers` is empty.
99101

@@ -303,18 +305,31 @@ client_secret = parse_client_secret('Bearer resource_metadata="https://...", cli
303305

304306
Returns `None` if the header doesn't contain `client_secret`.
305307

308+
## parse_use_id_token_as_bearer()
309+
310+
Extract the `use_id_token_as_bearer` flag from a `WWW-Authenticate` header. Custom extension (not in RFC 9728).
311+
312+
```python
313+
from vgi_rpc.http import parse_use_id_token_as_bearer
314+
315+
use_id_token = parse_use_id_token_as_bearer('Bearer resource_metadata="https://...", use_id_token_as_bearer="true"')
316+
# True
317+
```
318+
319+
Returns `False` if the header doesn't contain `use_id_token_as_bearer`.
320+
306321
## OAuthResourceMetadataResponse
307322

308323
Frozen dataclass returned by `http_oauth_metadata()` and `fetch_oauth_metadata()`.
309-
Same fields as `OAuthResourceMetadata` (the server-side config class), including `client_id` and `client_secret`.
324+
Same fields as `OAuthResourceMetadata` (the server-side config class), including `client_id`, `client_secret`, and `use_id_token_as_bearer`.
310325

311326
## Standards Compliance
312327

313328
- [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) — OAuth 2.0 Protected Resource Metadata
314329
- [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) — OAuth 2.0 Authorization Server Metadata
315330
- [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) — Bearer Token Usage
316331
- Compatible with MCP's OAuth implementation
317-
- **Custom extensions**: `client_id` and `client_secret` fields on `OAuthResourceMetadata` / `OAuthResourceMetadataResponse` and in `WWW-Authenticate` headers are not defined in RFC 9728
332+
- **Custom extensions**: `client_id`, `client_secret`, and `use_id_token_as_bearer` fields on `OAuthResourceMetadata` / `OAuthResourceMetadataResponse` and in `WWW-Authenticate` headers are not defined in RFC 9728
318333

319334
## Installation
320335

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.20"
3+
version = "0.1.21"
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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
parse_client_id,
2929
parse_client_secret,
3030
parse_resource_metadata_url,
31+
parse_use_id_token_as_bearer,
3132
)
3233

3334
# ---------------------------------------------------------------------------
@@ -127,6 +128,7 @@ def authenticate(req: falcon.Request) -> AuthContext:
127128
_METADATA_WITH_CLIENT_SECRET = dataclasses.replace(
128129
_METADATA, client_id="my-client-id", client_secret="my-client-secret"
129130
)
131+
_METADATA_WITH_ID_TOKEN = dataclasses.replace(_METADATA, use_id_token_as_bearer=True)
130132

131133

132134
# ---------------------------------------------------------------------------
@@ -467,6 +469,81 @@ def test_client_discovery_round_trip_with_client_secret(self) -> None:
467469
assert meta is not None
468470
assert meta.client_secret == "my-client-secret"
469471

472+
def test_use_id_token_as_bearer_in_well_known_json_when_true(self) -> None:
473+
"""use_id_token_as_bearer appears in well-known JSON when True."""
474+
server = RpcServer(_EchoService, _EchoImpl())
475+
client = make_sync_client(server, signing_key=b"k", oauth_resource_metadata=_METADATA_WITH_ID_TOKEN)
476+
resp = client.get("/.well-known/oauth-protected-resource")
477+
body = json.loads(resp.content)
478+
assert body["use_id_token_as_bearer"] is True
479+
480+
def test_use_id_token_as_bearer_absent_from_well_known_json_when_false(self) -> None:
481+
"""use_id_token_as_bearer absent from well-known JSON when False."""
482+
d = _METADATA.to_json_dict()
483+
assert "use_id_token_as_bearer" not in d
484+
485+
def test_use_id_token_as_bearer_in_www_authenticate(self) -> None:
486+
"""use_id_token_as_bearer appears in WWW-Authenticate header when True."""
487+
_priv, pub = _make_rsa_key()
488+
auth_fn = _make_local_auth(pub)
489+
server = RpcServer(_EchoService, _EchoImpl())
490+
client = make_sync_client(
491+
server,
492+
signing_key=b"k",
493+
authenticate=auth_fn,
494+
oauth_resource_metadata=_METADATA_WITH_ID_TOKEN,
495+
)
496+
resp = client.post(
497+
"/vgi/echo",
498+
content=b"garbage",
499+
headers={"Content-Type": "application/octet-stream"},
500+
)
501+
assert resp.status_code == 401
502+
www_auth = resp.headers.get("www-authenticate", "")
503+
assert 'use_id_token_as_bearer="true"' in www_auth
504+
505+
def test_use_id_token_as_bearer_absent_from_www_authenticate(self) -> None:
506+
"""use_id_token_as_bearer absent from WWW-Authenticate when False."""
507+
_priv, pub = _make_rsa_key()
508+
auth_fn = _make_local_auth(pub)
509+
server = RpcServer(_EchoService, _EchoImpl())
510+
client = make_sync_client(
511+
server,
512+
signing_key=b"k",
513+
authenticate=auth_fn,
514+
oauth_resource_metadata=_METADATA,
515+
)
516+
resp = client.post(
517+
"/vgi/echo",
518+
content=b"garbage",
519+
headers={"Content-Type": "application/octet-stream"},
520+
)
521+
assert resp.status_code == 401
522+
www_auth = resp.headers.get("www-authenticate", "")
523+
assert "use_id_token_as_bearer" not in www_auth
524+
525+
def test_parse_use_id_token_as_bearer_extracts_value(self) -> None:
526+
"""parse_use_id_token_as_bearer() extracts value from header."""
527+
header = (
528+
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/vgi"'
529+
', use_id_token_as_bearer="true"'
530+
)
531+
assert parse_use_id_token_as_bearer(header) is True
532+
533+
def test_parse_use_id_token_as_bearer_returns_false_when_absent(self) -> None:
534+
"""parse_use_id_token_as_bearer() returns False when not present."""
535+
assert parse_use_id_token_as_bearer("Bearer") is False
536+
assert parse_use_id_token_as_bearer('Bearer resource_metadata="https://example.com"') is False
537+
assert parse_use_id_token_as_bearer("") is False
538+
539+
def test_client_discovery_round_trip_with_use_id_token_as_bearer(self) -> None:
540+
"""Client discovers use_id_token_as_bearer set on server."""
541+
server = RpcServer(_EchoService, _EchoImpl())
542+
client = make_sync_client(server, signing_key=b"k", oauth_resource_metadata=_METADATA_WITH_ID_TOKEN)
543+
meta = http_oauth_metadata(client=client)
544+
assert meta is not None
545+
assert meta.use_id_token_as_bearer is True
546+
470547
def test_401_discovery_flow(self) -> None:
471548
"""Full 401-based discovery: get 401, parse header, fetch metadata."""
472549
_priv, pub = _make_rsa_key()

vgi_rpc/http/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
parse_client_id,
4040
parse_client_secret,
4141
parse_resource_metadata_url,
42+
parse_use_id_token_as_bearer,
4243
request_upload_urls,
4344
)
4445
from vgi_rpc.http._common import (
@@ -94,6 +95,7 @@
9495
"parse_client_id",
9596
"parse_client_secret",
9697
"parse_resource_metadata_url",
98+
"parse_use_id_token_as_bearer",
9799
"make_wsgi_app",
98100
"serve_http",
99101
"request_upload_urls",

vgi_rpc/http/_client.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,9 @@ class OAuthResourceMetadataResponse:
952952
authorization server. Custom extension (not in RFC 9728).
953953
client_secret: OAuth client_secret to use when authenticating with the
954954
authorization server. Custom extension (not in RFC 9728).
955+
use_id_token_as_bearer: When ``True``, the client should use the
956+
OIDC ``id_token`` as the Bearer token instead of the
957+
``access_token``. Custom extension (not in RFC 9728).
955958
956959
"""
957960

@@ -966,6 +969,7 @@ class OAuthResourceMetadataResponse:
966969
resource_tos_uri: str | None = None
967970
client_id: str | None = None
968971
client_secret: str | None = None
972+
use_id_token_as_bearer: bool = False
969973

970974

971975
def http_oauth_metadata(
@@ -1019,6 +1023,7 @@ def http_oauth_metadata(
10191023
_RESOURCE_METADATA_RE = re.compile(r'resource_metadata="([^"]+)"')
10201024
_CLIENT_ID_RE = re.compile(r'client_id="([^"]+)"')
10211025
_CLIENT_SECRET_RE = re.compile(r'client_secret="([^"]+)"')
1026+
_USE_ID_TOKEN_RE = re.compile(r'use_id_token_as_bearer="([^"]+)"')
10221027

10231028

10241029
def parse_resource_metadata_url(www_authenticate: str) -> str | None:
@@ -1076,6 +1081,25 @@ def parse_client_secret(www_authenticate: str) -> str | None:
10761081
return match.group(1) if match else None
10771082

10781083

1084+
def parse_use_id_token_as_bearer(www_authenticate: str) -> bool:
1085+
"""Extract the ``use_id_token_as_bearer`` flag from a ``WWW-Authenticate`` header.
1086+
1087+
Parses a ``Bearer`` challenge and returns ``True`` if the
1088+
``use_id_token_as_bearer`` parameter is present with value ``"true"``.
1089+
This is a custom extension (not defined in RFC 9728).
1090+
1091+
Args:
1092+
www_authenticate: The ``WWW-Authenticate`` header value.
1093+
1094+
Returns:
1095+
``True`` if the header contains ``use_id_token_as_bearer="true"``,
1096+
``False`` otherwise.
1097+
1098+
"""
1099+
match = _USE_ID_TOKEN_RE.search(www_authenticate)
1100+
return match.group(1) == "true" if match else False
1101+
1102+
10791103
def _parse_metadata_json(body: dict[str, Any]) -> OAuthResourceMetadataResponse:
10801104
"""Parse a JSON dict into an ``OAuthResourceMetadataResponse``.
10811105
@@ -1101,6 +1125,7 @@ def _parse_metadata_json(body: dict[str, Any]) -> OAuthResourceMetadataResponse:
11011125
resource_tos_uri=body.get("resource_tos_uri"),
11021126
client_id=body.get("client_id"),
11031127
client_secret=body.get("client_secret"),
1128+
use_id_token_as_bearer=body.get("use_id_token_as_bearer", False),
11041129
)
11051130

11061131

vgi_rpc/http/_oauth.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ class OAuthResourceMetadata:
4848
client_secret: OAuth client_secret that clients should use when
4949
authenticating with the authorization server. Custom
5050
extension (not defined in RFC 9728).
51+
use_id_token_as_bearer: When ``True``, tells clients to use the
52+
OIDC ``id_token`` as the Bearer token instead of the
53+
``access_token``. Custom extension (not defined in RFC 9728).
5154
5255
Raises:
5356
ValueError: If *resource* is empty or *authorization_servers* is empty.
@@ -65,6 +68,7 @@ class OAuthResourceMetadata:
6568
resource_tos_uri: str | None = None
6669
client_id: str | None = None
6770
client_secret: str | None = None
71+
use_id_token_as_bearer: bool = False
6872

6973
def __post_init__(self) -> None:
7074
"""Validate required fields."""
@@ -114,6 +118,8 @@ def to_json_dict(self) -> dict[str, object]:
114118
d["client_id"] = self.client_id
115119
if self.client_secret is not None:
116120
d["client_secret"] = self.client_secret
121+
if self.use_id_token_as_bearer:
122+
d["use_id_token_as_bearer"] = True
117123
return d
118124

119125

@@ -164,4 +170,6 @@ def _build_www_authenticate(metadata: OAuthResourceMetadata, prefix: str = "/vgi
164170
# as a "public" secret for native/SPA apps, not a truly confidential value.
165171
if metadata.client_secret is not None:
166172
challenge += f', client_secret="{metadata.client_secret}"'
173+
if metadata.use_id_token_as_bearer:
174+
challenge += ', use_id_token_as_bearer="true"'
167175
return challenge

0 commit comments

Comments
 (0)