|
26 | 26 | jwt_authenticate, |
27 | 27 | make_sync_client, |
28 | 28 | parse_client_id, |
| 29 | + parse_client_secret, |
29 | 30 | parse_resource_metadata_url, |
30 | 31 | ) |
31 | 32 |
|
@@ -123,6 +124,9 @@ def authenticate(req: falcon.Request) -> AuthContext: |
123 | 124 | ) |
124 | 125 |
|
125 | 126 | _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 | +) |
126 | 130 |
|
127 | 131 |
|
128 | 132 | # --------------------------------------------------------------------------- |
@@ -188,6 +192,7 @@ def test_well_known_omits_default_fields(self) -> None: |
188 | 192 | assert "bearer_methods_supported" not in d |
189 | 193 | assert "resource_name" not in d |
190 | 194 | assert "client_id" not in d |
| 195 | + assert "client_secret" not in d |
191 | 196 |
|
192 | 197 | def test_well_known_exempt_from_auth(self) -> None: |
193 | 198 | """Well-known endpoint is accessible even with auth enabled.""" |
@@ -271,7 +276,7 @@ def test_cache_control_header(self) -> None: |
271 | 276 | resp = client.get("/.well-known/oauth-protected-resource") |
272 | 277 | assert resp.status_code == 200 |
273 | 278 | cache = resp.headers.get("cache-control", "") |
274 | | - assert "max-age" in cache |
| 279 | + assert "max-age=60" in cache |
275 | 280 |
|
276 | 281 | def test_backwards_compatible(self) -> None: |
277 | 282 | """Server without oauth_resource_metadata still works normally.""" |
@@ -377,6 +382,91 @@ def test_client_discovery_round_trip_with_client_id(self) -> None: |
377 | 382 | assert meta is not None |
378 | 383 | assert meta.client_id == "my-client-id" |
379 | 384 |
|
| 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 | + |
380 | 470 | def test_401_discovery_flow(self) -> None: |
381 | 471 | """Full 401-based discovery: get 401, parse header, fetch metadata.""" |
382 | 472 | _priv, pub = _make_rsa_key() |
|
0 commit comments