Skip to content

Commit de1daa4

Browse files
rustyconoverclaude
andcommitted
Add device_code_client_id and device_code_client_secret to OAuth Resource Metadata and bump version to 0.1.22
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 42edddc commit de1daa4

6 files changed

Lines changed: 265 additions & 2 deletions

File tree

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.21"
3+
version = "0.1.22"
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: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
make_sync_client,
2828
parse_client_id,
2929
parse_client_secret,
30+
parse_device_code_client_id,
31+
parse_device_code_client_secret,
3032
parse_resource_metadata_url,
3133
parse_use_id_token_as_bearer,
3234
)
@@ -129,6 +131,10 @@ def authenticate(req: falcon.Request) -> AuthContext:
129131
_METADATA, client_id="my-client-id", client_secret="my-client-secret"
130132
)
131133
_METADATA_WITH_ID_TOKEN = dataclasses.replace(_METADATA, use_id_token_as_bearer=True)
134+
_METADATA_WITH_DEVICE_CODE_CLIENT_ID = dataclasses.replace(_METADATA, device_code_client_id="device-client-id")
135+
_METADATA_WITH_DEVICE_CODE_CLIENT_SECRET = dataclasses.replace(
136+
_METADATA, device_code_client_id="device-client-id", device_code_client_secret="device-client-secret"
137+
)
132138

133139

134140
# ---------------------------------------------------------------------------
@@ -195,6 +201,8 @@ def test_well_known_omits_default_fields(self) -> None:
195201
assert "resource_name" not in d
196202
assert "client_id" not in d
197203
assert "client_secret" not in d
204+
assert "device_code_client_id" not in d
205+
assert "device_code_client_secret" not in d
198206

199207
def test_well_known_exempt_from_auth(self) -> None:
200208
"""Well-known endpoint is accessible even with auth enabled."""
@@ -544,6 +552,184 @@ def test_client_discovery_round_trip_with_use_id_token_as_bearer(self) -> None:
544552
assert meta is not None
545553
assert meta.use_id_token_as_bearer is True
546554

555+
def test_device_code_client_id_rejects_unsafe_characters(self) -> None:
556+
"""device_code_client_id with non-URL-safe characters raises ValueError."""
557+
with pytest.raises(ValueError, match="URL-safe"):
558+
OAuthResourceMetadata(
559+
resource="https://example.com/vgi",
560+
authorization_servers=("https://auth.example.com",),
561+
device_code_client_id='bad"id',
562+
)
563+
with pytest.raises(ValueError, match="URL-safe"):
564+
OAuthResourceMetadata(
565+
resource="https://example.com/vgi",
566+
authorization_servers=("https://auth.example.com",),
567+
device_code_client_id="has space",
568+
)
569+
570+
def test_device_code_client_id_in_well_known_json(self) -> None:
571+
"""device_code_client_id appears in well-known JSON when set."""
572+
server = RpcServer(_EchoService, _EchoImpl())
573+
client = make_sync_client(
574+
server, signing_key=b"k", oauth_resource_metadata=_METADATA_WITH_DEVICE_CODE_CLIENT_ID
575+
)
576+
resp = client.get("/.well-known/oauth-protected-resource")
577+
body = json.loads(resp.content)
578+
assert body["device_code_client_id"] == "device-client-id"
579+
580+
def test_device_code_client_id_in_www_authenticate(self) -> None:
581+
"""device_code_client_id appears in WWW-Authenticate header when set."""
582+
_priv, pub = _make_rsa_key()
583+
auth_fn = _make_local_auth(pub)
584+
server = RpcServer(_EchoService, _EchoImpl())
585+
client = make_sync_client(
586+
server,
587+
signing_key=b"k",
588+
authenticate=auth_fn,
589+
oauth_resource_metadata=_METADATA_WITH_DEVICE_CODE_CLIENT_ID,
590+
)
591+
resp = client.post(
592+
"/vgi/echo",
593+
content=b"garbage",
594+
headers={"Content-Type": "application/octet-stream"},
595+
)
596+
assert resp.status_code == 401
597+
www_auth = resp.headers.get("www-authenticate", "")
598+
assert 'device_code_client_id="device-client-id"' in www_auth
599+
600+
def test_device_code_client_id_absent_from_www_authenticate(self) -> None:
601+
"""device_code_client_id absent from WWW-Authenticate when not set."""
602+
_priv, pub = _make_rsa_key()
603+
auth_fn = _make_local_auth(pub)
604+
server = RpcServer(_EchoService, _EchoImpl())
605+
client = make_sync_client(
606+
server,
607+
signing_key=b"k",
608+
authenticate=auth_fn,
609+
oauth_resource_metadata=_METADATA,
610+
)
611+
resp = client.post(
612+
"/vgi/echo",
613+
content=b"garbage",
614+
headers={"Content-Type": "application/octet-stream"},
615+
)
616+
assert resp.status_code == 401
617+
www_auth = resp.headers.get("www-authenticate", "")
618+
assert "device_code_client_id" not in www_auth
619+
620+
def test_parse_device_code_client_id_extracts_value(self) -> None:
621+
"""parse_device_code_client_id() extracts value from header."""
622+
header = (
623+
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/vgi"'
624+
', device_code_client_id="my-device-app"'
625+
)
626+
assert parse_device_code_client_id(header) == "my-device-app"
627+
628+
def test_parse_device_code_client_id_returns_none_when_absent(self) -> None:
629+
"""parse_device_code_client_id() returns None when not present."""
630+
assert parse_device_code_client_id("Bearer") is None
631+
assert parse_device_code_client_id('Bearer resource_metadata="https://example.com"') is None
632+
assert parse_device_code_client_id("") is None
633+
634+
def test_client_discovery_round_trip_with_device_code_client_id(self) -> None:
635+
"""Client discovers device_code_client_id set on server."""
636+
server = RpcServer(_EchoService, _EchoImpl())
637+
client = make_sync_client(
638+
server, signing_key=b"k", oauth_resource_metadata=_METADATA_WITH_DEVICE_CODE_CLIENT_ID
639+
)
640+
meta = http_oauth_metadata(client=client)
641+
assert meta is not None
642+
assert meta.device_code_client_id == "device-client-id"
643+
644+
def test_device_code_client_secret_rejects_unsafe_characters(self) -> None:
645+
"""device_code_client_secret with non-URL-safe characters raises ValueError."""
646+
with pytest.raises(ValueError, match="URL-safe"):
647+
OAuthResourceMetadata(
648+
resource="https://example.com/vgi",
649+
authorization_servers=("https://auth.example.com",),
650+
device_code_client_secret='bad"secret',
651+
)
652+
with pytest.raises(ValueError, match="URL-safe"):
653+
OAuthResourceMetadata(
654+
resource="https://example.com/vgi",
655+
authorization_servers=("https://auth.example.com",),
656+
device_code_client_secret="has space",
657+
)
658+
659+
def test_device_code_client_secret_in_well_known_json(self) -> None:
660+
"""device_code_client_secret appears in well-known JSON when set."""
661+
server = RpcServer(_EchoService, _EchoImpl())
662+
client = make_sync_client(
663+
server, signing_key=b"k", oauth_resource_metadata=_METADATA_WITH_DEVICE_CODE_CLIENT_SECRET
664+
)
665+
resp = client.get("/.well-known/oauth-protected-resource")
666+
body = json.loads(resp.content)
667+
assert body["device_code_client_secret"] == "device-client-secret"
668+
669+
def test_device_code_client_secret_in_www_authenticate(self) -> None:
670+
"""device_code_client_secret appears in WWW-Authenticate header when set."""
671+
_priv, pub = _make_rsa_key()
672+
auth_fn = _make_local_auth(pub)
673+
server = RpcServer(_EchoService, _EchoImpl())
674+
client = make_sync_client(
675+
server,
676+
signing_key=b"k",
677+
authenticate=auth_fn,
678+
oauth_resource_metadata=_METADATA_WITH_DEVICE_CODE_CLIENT_SECRET,
679+
)
680+
resp = client.post(
681+
"/vgi/echo",
682+
content=b"garbage",
683+
headers={"Content-Type": "application/octet-stream"},
684+
)
685+
assert resp.status_code == 401
686+
www_auth = resp.headers.get("www-authenticate", "")
687+
assert 'device_code_client_secret="device-client-secret"' in www_auth
688+
689+
def test_device_code_client_secret_absent_from_www_authenticate(self) -> None:
690+
"""device_code_client_secret absent from WWW-Authenticate when not set."""
691+
_priv, pub = _make_rsa_key()
692+
auth_fn = _make_local_auth(pub)
693+
server = RpcServer(_EchoService, _EchoImpl())
694+
client = make_sync_client(
695+
server,
696+
signing_key=b"k",
697+
authenticate=auth_fn,
698+
oauth_resource_metadata=_METADATA,
699+
)
700+
resp = client.post(
701+
"/vgi/echo",
702+
content=b"garbage",
703+
headers={"Content-Type": "application/octet-stream"},
704+
)
705+
assert resp.status_code == 401
706+
www_auth = resp.headers.get("www-authenticate", "")
707+
assert "device_code_client_secret" not in www_auth
708+
709+
def test_parse_device_code_client_secret_extracts_value(self) -> None:
710+
"""parse_device_code_client_secret() extracts value from header."""
711+
header = (
712+
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/vgi"'
713+
', device_code_client_id="my-device-app", device_code_client_secret="my-device-secret"'
714+
)
715+
assert parse_device_code_client_secret(header) == "my-device-secret"
716+
717+
def test_parse_device_code_client_secret_returns_none_when_absent(self) -> None:
718+
"""parse_device_code_client_secret() returns None when not present."""
719+
assert parse_device_code_client_secret("Bearer") is None
720+
assert parse_device_code_client_secret('Bearer resource_metadata="https://example.com"') is None
721+
assert parse_device_code_client_secret("") is None
722+
723+
def test_client_discovery_round_trip_with_device_code_client_secret(self) -> None:
724+
"""Client discovers device_code_client_secret set on server."""
725+
server = RpcServer(_EchoService, _EchoImpl())
726+
client = make_sync_client(
727+
server, signing_key=b"k", oauth_resource_metadata=_METADATA_WITH_DEVICE_CODE_CLIENT_SECRET
728+
)
729+
meta = http_oauth_metadata(client=client)
730+
assert meta is not None
731+
assert meta.device_code_client_secret == "device-client-secret"
732+
547733
def test_401_discovery_flow(self) -> None:
548734
"""Full 401-based discovery: get 401, parse header, fetch metadata."""
549735
_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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
http_oauth_metadata,
3939
parse_client_id,
4040
parse_client_secret,
41+
parse_device_code_client_id,
42+
parse_device_code_client_secret,
4143
parse_resource_metadata_url,
4244
parse_use_id_token_as_bearer,
4345
request_upload_urls,
@@ -94,6 +96,8 @@
9496
"make_sync_client",
9597
"parse_client_id",
9698
"parse_client_secret",
99+
"parse_device_code_client_id",
100+
"parse_device_code_client_secret",
97101
"parse_resource_metadata_url",
98102
"parse_use_id_token_as_bearer",
99103
"make_wsgi_app",

vgi_rpc/http/_client.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,11 @@ class OAuthResourceMetadataResponse:
955955
use_id_token_as_bearer: When ``True``, the client should use the
956956
OIDC ``id_token`` as the Bearer token instead of the
957957
``access_token``. Custom extension (not in RFC 9728).
958+
device_code_client_id: OAuth client_id to use specifically for the
959+
device code grant flow. Custom extension (not in RFC 9728).
960+
device_code_client_secret: OAuth client_secret to use specifically
961+
for the device code grant flow. Custom extension (not in
962+
RFC 9728).
958963
959964
"""
960965

@@ -970,6 +975,8 @@ class OAuthResourceMetadataResponse:
970975
client_id: str | None = None
971976
client_secret: str | None = None
972977
use_id_token_as_bearer: bool = False
978+
device_code_client_id: str | None = None
979+
device_code_client_secret: str | None = None
973980

974981

975982
def http_oauth_metadata(
@@ -1024,6 +1031,8 @@ def http_oauth_metadata(
10241031
_CLIENT_ID_RE = re.compile(r'client_id="([^"]+)"')
10251032
_CLIENT_SECRET_RE = re.compile(r'client_secret="([^"]+)"')
10261033
_USE_ID_TOKEN_RE = re.compile(r'use_id_token_as_bearer="([^"]+)"')
1034+
_DEVICE_CODE_CLIENT_ID_RE = re.compile(r'device_code_client_id="([^"]+)"')
1035+
_DEVICE_CODE_CLIENT_SECRET_RE = re.compile(r'device_code_client_secret="([^"]+)"')
10271036

10281037

10291038
def parse_resource_metadata_url(www_authenticate: str) -> str | None:
@@ -1100,6 +1109,42 @@ def parse_use_id_token_as_bearer(www_authenticate: str) -> bool:
11001109
return match.group(1) == "true" if match else False
11011110

11021111

1112+
def parse_device_code_client_id(www_authenticate: str) -> str | None:
1113+
"""Extract the ``device_code_client_id`` from a ``WWW-Authenticate`` header.
1114+
1115+
Parses a ``Bearer`` challenge and returns the ``device_code_client_id``
1116+
parameter value, or ``None`` if not present. This is a custom extension
1117+
(not defined in RFC 9728).
1118+
1119+
Args:
1120+
www_authenticate: The ``WWW-Authenticate`` header value.
1121+
1122+
Returns:
1123+
The device_code_client_id string, or ``None`` if not present.
1124+
1125+
"""
1126+
match = _DEVICE_CODE_CLIENT_ID_RE.search(www_authenticate)
1127+
return match.group(1) if match else None
1128+
1129+
1130+
def parse_device_code_client_secret(www_authenticate: str) -> str | None:
1131+
"""Extract the ``device_code_client_secret`` from a ``WWW-Authenticate`` header.
1132+
1133+
Parses a ``Bearer`` challenge and returns the ``device_code_client_secret``
1134+
parameter value, or ``None`` if not present. This is a custom extension
1135+
(not defined in RFC 9728).
1136+
1137+
Args:
1138+
www_authenticate: The ``WWW-Authenticate`` header value.
1139+
1140+
Returns:
1141+
The device_code_client_secret string, or ``None`` if not present.
1142+
1143+
"""
1144+
match = _DEVICE_CODE_CLIENT_SECRET_RE.search(www_authenticate)
1145+
return match.group(1) if match else None
1146+
1147+
11031148
def _parse_metadata_json(body: dict[str, Any]) -> OAuthResourceMetadataResponse:
11041149
"""Parse a JSON dict into an ``OAuthResourceMetadataResponse``.
11051150
@@ -1126,6 +1171,8 @@ def _parse_metadata_json(body: dict[str, Any]) -> OAuthResourceMetadataResponse:
11261171
client_id=body.get("client_id"),
11271172
client_secret=body.get("client_secret"),
11281173
use_id_token_as_bearer=body.get("use_id_token_as_bearer", False),
1174+
device_code_client_id=body.get("device_code_client_id"),
1175+
device_code_client_secret=body.get("device_code_client_secret"),
11291176
)
11301177

11311178

vgi_rpc/http/_oauth.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ class OAuthResourceMetadata:
5151
use_id_token_as_bearer: When ``True``, tells clients to use the
5252
OIDC ``id_token`` as the Bearer token instead of the
5353
``access_token``. Custom extension (not defined in RFC 9728).
54+
device_code_client_id: OAuth client_id that clients should use
55+
specifically for the device code grant flow. Custom extension
56+
(not defined in RFC 9728).
57+
device_code_client_secret: OAuth client_secret that clients should
58+
use specifically for the device code grant flow. Custom
59+
extension (not defined in RFC 9728).
5460
5561
Raises:
5662
ValueError: If *resource* is empty or *authorization_servers* is empty.
@@ -69,6 +75,8 @@ class OAuthResourceMetadata:
6975
client_id: str | None = None
7076
client_secret: str | None = None
7177
use_id_token_as_bearer: bool = False
78+
device_code_client_id: str | None = None
79+
device_code_client_secret: str | None = None
7280

7381
def __post_init__(self) -> None:
7482
"""Validate required fields."""
@@ -86,6 +94,16 @@ def __post_init__(self) -> None:
8694
"OAuthResourceMetadata.client_secret must contain only URL-safe characters "
8795
"(alphanumeric, hyphen, underscore, period, tilde)"
8896
)
97+
if self.device_code_client_id is not None and not _URL_SAFE_RE.fullmatch(self.device_code_client_id):
98+
raise ValueError(
99+
"OAuthResourceMetadata.device_code_client_id must contain only URL-safe characters "
100+
"(alphanumeric, hyphen, underscore, period, tilde)"
101+
)
102+
if self.device_code_client_secret is not None and not _URL_SAFE_RE.fullmatch(self.device_code_client_secret):
103+
raise ValueError(
104+
"OAuthResourceMetadata.device_code_client_secret must contain only URL-safe characters "
105+
"(alphanumeric, hyphen, underscore, period, tilde)"
106+
)
89107

90108
def to_json_dict(self) -> dict[str, object]:
91109
"""Serialize to a JSON-compatible dict per RFC 9728.
@@ -120,6 +138,10 @@ def to_json_dict(self) -> dict[str, object]:
120138
d["client_secret"] = self.client_secret
121139
if self.use_id_token_as_bearer:
122140
d["use_id_token_as_bearer"] = True
141+
if self.device_code_client_id is not None:
142+
d["device_code_client_id"] = self.device_code_client_id
143+
if self.device_code_client_secret is not None:
144+
d["device_code_client_secret"] = self.device_code_client_secret
123145
return d
124146

125147

@@ -172,4 +194,8 @@ def _build_www_authenticate(metadata: OAuthResourceMetadata, prefix: str = "/vgi
172194
challenge += f', client_secret="{metadata.client_secret}"'
173195
if metadata.use_id_token_as_bearer:
174196
challenge += ', use_id_token_as_bearer="true"'
197+
if metadata.device_code_client_id is not None:
198+
challenge += f', device_code_client_id="{metadata.device_code_client_id}"'
199+
if metadata.device_code_client_secret is not None:
200+
challenge += f', device_code_client_secret="{metadata.device_code_client_secret}"'
175201
return challenge

0 commit comments

Comments
 (0)