internetdata 2.5.0__tar.gz → 2.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {internetdata-2.5.0 → internetdata-2.6.0}/PKG-INFO +21 -1
  2. {internetdata-2.5.0 → internetdata-2.6.0}/README.md +20 -0
  3. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/__init__.py +3 -1
  4. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_core.py +58 -1
  5. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/aio.py +53 -0
  6. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/client.py +59 -1
  7. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/models.py +11 -0
  8. {internetdata-2.5.0 → internetdata-2.6.0}/tests/helpers.py +17 -0
  9. {internetdata-2.5.0 → internetdata-2.6.0}/tests/test_oauth.py +74 -4
  10. {internetdata-2.5.0 → internetdata-2.6.0}/.gitignore +0 -0
  11. {internetdata-2.5.0 → internetdata-2.6.0}/LICENSE +0 -0
  12. {internetdata-2.5.0 → internetdata-2.6.0}/pyproject.toml +0 -0
  13. {internetdata-2.5.0 → internetdata-2.6.0}/scripts/download-spec.sh +0 -0
  14. {internetdata-2.5.0 → internetdata-2.6.0}/scripts/generate.sh +0 -0
  15. {internetdata-2.5.0 → internetdata-2.6.0}/scripts/publish.sh +0 -0
  16. {internetdata-2.5.0 → internetdata-2.6.0}/scripts/v2_subset.py +0 -0
  17. {internetdata-2.5.0 → internetdata-2.6.0}/spec/openapi.yaml +0 -0
  18. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/__init__.py +0 -0
  19. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/__init__.py +0 -0
  20. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/account/__init__.py +0 -0
  21. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/account/account_identity.py +0 -0
  22. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/__init__.py +0 -0
  23. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/account_create_apikey.py +0 -0
  24. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/account_list_apikeys.py +0 -0
  25. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/account_reveal_apikey.py +0 -0
  26. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/account_revoke_apikey.py +0 -0
  27. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/account_rotate_apikey.py +0 -0
  28. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/__init__.py +0 -0
  29. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/oauth_authorize.py +0 -0
  30. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/oauth_device_authorization.py +0 -0
  31. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/oauth_metadata.py +0 -0
  32. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/oauth_revoke.py +0 -0
  33. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/oauth_token.py +0 -0
  34. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
  35. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
  36. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
  37. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
  38. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
  39. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
  40. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/organization/__init__.py +0 -0
  41. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/organization/account_org.py +0 -0
  42. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/organization/account_org_members.py +0 -0
  43. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/client.py +0 -0
  44. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/errors.py +0 -0
  45. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/__init__.py +0 -0
  46. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_create_apikey_request.py +0 -0
  47. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_created_apikey.py +0 -0
  48. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_org.py +0 -0
  49. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_org_ref.py +0 -0
  50. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_org_wrap.py +0 -0
  51. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_rc.py +0 -0
  52. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_revealed_apikey.py +0 -0
  53. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_user.py +0 -0
  54. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/apikey_detail.py +0 -0
  55. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/apikey_list.py +0 -0
  56. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database.py +0 -0
  57. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
  58. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_format.py +0 -0
  59. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
  60. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
  61. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
  62. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata.py +0 -0
  63. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
  64. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
  65. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
  66. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_sample_size.py +0 -0
  67. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
  68. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
  69. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_version.py +0 -0
  70. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/db_checksums.py +0 -0
  71. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/device_authorization.py +0 -0
  72. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/device_authorization_request.py +0 -0
  73. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/download.py +0 -0
  74. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/download_outcome.py +0 -0
  75. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/error.py +0 -0
  76. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/identity.py +0 -0
  77. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
  78. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
  79. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/oauth_authorize_code_challenge_method.py +0 -0
  80. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/oauth_authorize_response_type.py +0 -0
  81. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/oauth_error.py +0 -0
  82. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/oauth_metadata.py +0 -0
  83. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/oauth_revoke_response_200.py +0 -0
  84. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/revoke_request.py +0 -0
  85. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/standing.py +0 -0
  86. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/token_request.py +0 -0
  87. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/token_response.py +0 -0
  88. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/types.py +0 -0
  89. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/errors.py +0 -0
  90. {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/py.typed +0 -0
  91. {internetdata-2.5.0 → internetdata-2.6.0}/testdata/testdata.json +0 -0
  92. {internetdata-2.5.0 → internetdata-2.6.0}/tests/conftest.py +0 -0
  93. {internetdata-2.5.0 → internetdata-2.6.0}/tests/test_client.py +0 -0
  94. {internetdata-2.5.0 → internetdata-2.6.0}/tests/test_conformance.py +0 -0
  95. {internetdata-2.5.0 → internetdata-2.6.0}/tests/test_download.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: internetdata
3
- Version: 2.5.0
3
+ Version: 2.6.0
4
4
  Summary: Official Python client library for the InternetData API. Download and verify licensed IP datasets.
5
5
  Project-URL: Homepage, https://internetdata.io
6
6
  Project-URL: Documentation, https://docs.internetdata.io
@@ -203,6 +203,26 @@ client = InternetData(tokens.apikey)
203
203
 
204
204
  A refusal raises `OauthAccessDeniedError` and a code that ran out raises `OauthExpiredTokenError`, and client IDs are issued on request from support@internetdata.io. `client.oauth.revoke("your-client-id", tokens.refresh_token)` signs the machine out.
205
205
 
206
+ ### Sign in with OAuth (authorization code)
207
+
208
+ An app that can take a browser redirect signs the person in there instead, with a PKCE pair made for that one sign-in:
209
+
210
+ ```python
211
+ from internetdata import InternetData
212
+
213
+ signin = InternetData()
214
+ redirect_uri = "http://127.0.0.1:8765/callback"
215
+ pkce = signin.oauth.create_pkce()
216
+
217
+ url = signin.oauth.authorization_url(
218
+ "your-client-id", redirect_uri, pkce.challenge, scope="apikeys.use", state="your-state"
219
+ )
220
+ # Open url in the browser. Its redirect to redirect_uri carries code and state.
221
+ tokens = signin.oauth.exchange_authorization_code("your-client-id", code, pkce.verifier, redirect_uri)
222
+ ```
223
+
224
+ Check that `state` came back as you sent it before you exchange `code`, which works once. The client ID can also be the https URL of a client metadata document your app serves, and such an app is never handed a key, so `tokens.apikey` stays `None`.
225
+
206
226
  ## Other Libraries
207
227
 
208
228
  There are official InternetData client libraries available for many languages including PHP, Python, Go, Java, Ruby, and many popular frameworks such as Django, Rails, and Laravel. See our GitHub at https://github.com/internetdata for more.
@@ -169,6 +169,26 @@ client = InternetData(tokens.apikey)
169
169
 
170
170
  A refusal raises `OauthAccessDeniedError` and a code that ran out raises `OauthExpiredTokenError`, and client IDs are issued on request from support@internetdata.io. `client.oauth.revoke("your-client-id", tokens.refresh_token)` signs the machine out.
171
171
 
172
+ ### Sign in with OAuth (authorization code)
173
+
174
+ An app that can take a browser redirect signs the person in there instead, with a PKCE pair made for that one sign-in:
175
+
176
+ ```python
177
+ from internetdata import InternetData
178
+
179
+ signin = InternetData()
180
+ redirect_uri = "http://127.0.0.1:8765/callback"
181
+ pkce = signin.oauth.create_pkce()
182
+
183
+ url = signin.oauth.authorization_url(
184
+ "your-client-id", redirect_uri, pkce.challenge, scope="apikeys.use", state="your-state"
185
+ )
186
+ # Open url in the browser. Its redirect to redirect_uri carries code and state.
187
+ tokens = signin.oauth.exchange_authorization_code("your-client-id", code, pkce.verifier, redirect_uri)
188
+ ```
189
+
190
+ Check that `state` came back as you sent it before you exchange `code`, which works once. The client ID can also be the https URL of a client metadata document your app serves, and such an app is never handed a key, so `tokens.apikey` stays `None`.
191
+
172
192
  ## Other Libraries
173
193
 
174
194
  There are official InternetData client libraries available for many languages including PHP, Python, Go, Java, Ruby, and many popular frameworks such as Django, Rails, and Laravel. See our GitHub at https://github.com/internetdata for more.
@@ -34,11 +34,12 @@ from .models import (
34
34
  MetadataColumn,
35
35
  OauthMetadata,
36
36
  Outcome,
37
+ Pkce,
37
38
  Standing,
38
39
  TokenResponse,
39
40
  )
40
41
 
41
- __version__ = "2.5.0"
42
+ __version__ = "2.6.0"
42
43
 
43
44
  __all__ = [
44
45
  "DATABASE_FORMATS",
@@ -66,6 +67,7 @@ __all__ = [
66
67
  "OauthExpiredTokenError",
67
68
  "OauthMetadata",
68
69
  "Outcome",
70
+ "Pkce",
69
71
  "Standing",
70
72
  "TokenResponse",
71
73
  "__version__",
@@ -4,24 +4,28 @@ each attempt, response unwrapping, and the retry policy."""
4
4
  from __future__ import annotations
5
5
 
6
6
  import asyncio
7
+ import base64
7
8
  import contextlib
8
9
  import contextvars
10
+ import hashlib
9
11
  import json
10
12
  import math
11
13
  import os
14
+ import secrets
12
15
  import threading
13
16
  import time
14
17
  from collections.abc import Awaitable, Callable, Iterator
15
18
  from pathlib import Path
16
19
  from types import ModuleType
17
20
  from typing import IO, Any, TypeVar, cast
21
+ from urllib.parse import quote
18
22
 
19
23
  import httpx
20
24
 
21
25
  from ._generated.client import AuthenticatedClient, Client
22
26
  from ._generated.types import Response
23
27
  from .errors import InternetDataError, error_from_response, oauth_error_from
24
- from .models import Database, Download, to_database, to_download
28
+ from .models import Database, Download, Pkce, to_database, to_download
25
29
 
26
30
  DEFAULT_BASE_URL = "https://internetdata.io"
27
31
  DEFAULT_RETRIES = 2
@@ -32,6 +36,7 @@ OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server"
32
36
  OAUTH_DEVICE_AUTHORIZATION_PATH = "/oauth/device_authorization"
33
37
  OAUTH_TOKEN_PATH = "/oauth/token"
34
38
  OAUTH_REVOKE_PATH = "/oauth/revoke"
39
+ OAUTH_AUTHORIZE_PATH = "/oauth/authorize"
35
40
  DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
36
41
 
37
42
  # One chunk of a transfer, and therefore the ceiling on what a download of any size
@@ -337,6 +342,58 @@ async def oauth_request_async(
337
342
  return oauth_checked(await exchange_async(http, req, bound))
338
343
 
339
344
 
345
+ def authorization_url(
346
+ base_url: str,
347
+ client_id: str,
348
+ redirect_uri: str,
349
+ code_challenge: str,
350
+ optional: dict[str, str | None],
351
+ ) -> str:
352
+ """The authorization code flow's URL, built with no request: the five parameters every
353
+ one carries, then `scope`, `state` and `resource` when given and not empty.
354
+
355
+ Each value is percent-encoded over UTF-8 with only A-Z a-z 0-9 - . _ ~ left literal, so
356
+ a space is %20 and never +. A required value that is empty, or has no UTF-8 (a lone
357
+ surrogate), is refused.
358
+ """
359
+ required = {
360
+ "client_id": client_id,
361
+ "redirect_uri": redirect_uri,
362
+ "code_challenge": code_challenge,
363
+ }
364
+ for name, value in required.items():
365
+ if not isinstance(value, str) or not value:
366
+ raise ValueError(f"{name} must be a non-empty string, not {value!r}")
367
+ params = [
368
+ ("response_type", "code"),
369
+ *required.items(),
370
+ ("code_challenge_method", "S256"),
371
+ *((name, value) for name, value in optional.items() if value),
372
+ ]
373
+ encoded = []
374
+ for name, value in params:
375
+ try:
376
+ encoded.append(f"{name}={quote(value, safe='')}")
377
+ except UnicodeEncodeError as exc:
378
+ raise ValueError(f"{name} has no UTF-8 to send: {value!r}") from exc
379
+ return f"{base_url.rstrip('/')}{OAUTH_AUTHORIZE_PATH}?{'&'.join(encoded)}"
380
+
381
+
382
+ def create_pkce() -> Pkce:
383
+ """A fresh PKCE pair, from 32 bytes of the system's secure random source."""
384
+ verifier = _base64url(secrets.token_bytes(32))
385
+ return Pkce(verifier=verifier, challenge=pkce_challenge(verifier))
386
+
387
+
388
+ def pkce_challenge(verifier: str) -> str:
389
+ """The `S256` challenge for a PKCE verifier: its SHA-256, as unpadded base64url."""
390
+ return _base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
391
+
392
+
393
+ def _base64url(raw: bytes) -> str:
394
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
395
+
396
+
340
397
  def oauth_checked(res: httpx.Response) -> httpx.Response:
341
398
  """A 2xx as it came, or the failure it describes.
342
399
 
@@ -30,16 +30,19 @@ from ._core import (
30
30
  AsyncClock,
31
31
  as_error,
32
32
  assert_whole_transfer,
33
+ authorization_url,
33
34
  build_async_transfer_client,
34
35
  build_client,
35
36
  check_timeout,
36
37
  checksums_of,
38
+ create_pkce,
37
39
  databases_of,
38
40
  downloads_of,
39
41
  oauth_body,
40
42
  oauth_request_async,
41
43
  parse_body,
42
44
  part_file,
45
+ pkce_challenge,
43
46
  redirect_location,
44
47
  request_async,
45
48
  retry_delay,
@@ -63,6 +66,7 @@ from .models import (
63
66
  Download,
64
67
  Format,
65
68
  OauthMetadata,
69
+ Pkce,
66
70
  TokenResponse,
67
71
  to_device_authorization,
68
72
  to_metadata,
@@ -99,6 +103,7 @@ class AsyncInternetData:
99
103
  ) -> None:
100
104
  timeout = check_timeout(timeout)
101
105
  self._client = build_client(api_key, base_url, timeout, transport)
106
+ self._base_url = base_url
102
107
  self._transfer = build_async_transfer_client(timeout, transport)
103
108
  self._retries = retries
104
109
  self._timeout = timeout
@@ -381,6 +386,54 @@ class AsyncOauthApi:
381
386
  }
382
387
  return await self._exchange(form, timeout)
383
388
 
389
+ async def exchange_authorization_code(
390
+ self,
391
+ client_id: str,
392
+ code: str,
393
+ code_verifier: str,
394
+ redirect_uri: str,
395
+ *,
396
+ timeout: float | None = None,
397
+ ) -> TokenResponse:
398
+ """Trade a redirect's code for tokens, once; see `OauthApi.exchange_authorization_code`."""
399
+ form = {
400
+ "grant_type": "authorization_code",
401
+ "code": code,
402
+ "redirect_uri": redirect_uri,
403
+ "client_id": client_id,
404
+ "code_verifier": code_verifier,
405
+ }
406
+ return await self._exchange(form, timeout)
407
+
408
+ def authorization_url(
409
+ self,
410
+ client_id: str,
411
+ redirect_uri: str,
412
+ code_challenge: str,
413
+ *,
414
+ scope: str | None = None,
415
+ state: str | None = None,
416
+ resource: str | None = None,
417
+ ) -> str:
418
+ """The authorization code flow's URL, made with no request; see
419
+ `OauthApi.authorization_url`.
420
+ """
421
+ return authorization_url(
422
+ self._owner._base_url,
423
+ client_id,
424
+ redirect_uri,
425
+ code_challenge,
426
+ {"scope": scope, "state": state, "resource": resource},
427
+ )
428
+
429
+ def create_pkce(self) -> Pkce:
430
+ """A fresh PKCE pair for one sign-in, from the system's secure random source."""
431
+ return create_pkce()
432
+
433
+ def pkce_challenge(self, verifier: str) -> str:
434
+ """The `S256` challenge for a PKCE verifier: its SHA-256, as unpadded base64url."""
435
+ return pkce_challenge(verifier)
436
+
384
437
  async def revoke(self, client_id: str, token: str, *, timeout: float | None = None) -> None:
385
438
  """End a token; revoking the refresh token signs the machine out."""
386
439
 
@@ -25,16 +25,19 @@ from ._core import (
25
25
  Clock,
26
26
  as_error,
27
27
  assert_whole_transfer,
28
+ authorization_url,
28
29
  build_client,
29
30
  build_transfer_client,
30
31
  check_timeout,
31
32
  checksums_of,
33
+ create_pkce,
32
34
  databases_of,
33
35
  downloads_of,
34
36
  oauth_body,
35
37
  oauth_request,
36
38
  parse_body,
37
39
  part_file,
40
+ pkce_challenge,
38
41
  redirect_location,
39
42
  request,
40
43
  retry_delay,
@@ -58,6 +61,7 @@ from .models import (
58
61
  Download,
59
62
  Format,
60
63
  OauthMetadata,
64
+ Pkce,
61
65
  TokenResponse,
62
66
  to_device_authorization,
63
67
  to_metadata,
@@ -107,6 +111,7 @@ class InternetData:
107
111
  ) -> None:
108
112
  timeout = check_timeout(timeout)
109
113
  self._client = build_client(api_key, base_url, timeout, transport)
114
+ self._base_url = base_url
110
115
  self._transfer = build_transfer_client(timeout, transport)
111
116
  self._retries = retries
112
117
  self._timeout = timeout
@@ -339,7 +344,8 @@ class DatabaseApi:
339
344
 
340
345
  class OauthApi:
341
346
  """Signing a person in with the OAuth device flow, so a program running on their own
342
- machine can be handed one of their API keys instead of asking them to paste it.
347
+ machine can be handed one of their API keys instead of asking them to paste it, or
348
+ through a browser redirect with the authorization code flow.
343
349
 
344
350
  No request here carries this client's API key, and none needs one: build the client
345
351
  with no key to sign in, then a second one with the key the sign-in hands over. The
@@ -412,6 +418,58 @@ class OauthApi:
412
418
  }
413
419
  return self._exchange(form, timeout)
414
420
 
421
+ def exchange_authorization_code(
422
+ self,
423
+ client_id: str,
424
+ code: str,
425
+ code_verifier: str,
426
+ redirect_uri: str,
427
+ *,
428
+ timeout: float | None = None,
429
+ ) -> TokenResponse:
430
+ """Trade the `code` a sign-in's redirect brought back for tokens, once: the server
431
+ spends the code on first read, before it checks `code_verifier`, the PKCE verifier
432
+ whose challenge went into the authorization URL. `redirect_uri` is that URL's, exactly.
433
+ """
434
+ form = {
435
+ "grant_type": "authorization_code",
436
+ "code": code,
437
+ "redirect_uri": redirect_uri,
438
+ "client_id": client_id,
439
+ "code_verifier": code_verifier,
440
+ }
441
+ return self._exchange(form, timeout)
442
+
443
+ def authorization_url(
444
+ self,
445
+ client_id: str,
446
+ redirect_uri: str,
447
+ code_challenge: str,
448
+ *,
449
+ scope: str | None = None,
450
+ state: str | None = None,
451
+ resource: str | None = None,
452
+ ) -> str:
453
+ """The URL to open in the person's browser for the authorization code flow. Makes no
454
+ request. Once they decide, the server redirects to `redirect_uri` with a `code` for
455
+ `exchange_authorization_code` (and `state`, when one was given), or with an `error`.
456
+ """
457
+ return authorization_url(
458
+ self._owner._base_url,
459
+ client_id,
460
+ redirect_uri,
461
+ code_challenge,
462
+ {"scope": scope, "state": state, "resource": resource},
463
+ )
464
+
465
+ def create_pkce(self) -> Pkce:
466
+ """A fresh PKCE pair for one sign-in, from the system's secure random source."""
467
+ return create_pkce()
468
+
469
+ def pkce_challenge(self, verifier: str) -> str:
470
+ """The `S256` challenge for a PKCE verifier: its SHA-256, as unpadded base64url."""
471
+ return pkce_challenge(verifier)
472
+
415
473
  def revoke(self, client_id: str, token: str, *, timeout: float | None = None) -> None:
416
474
  """End a token. A refresh token ends the whole sign-in and every token it issued, so
417
475
  revoking it is how a program signs the machine out."""
@@ -278,6 +278,17 @@ class DeviceAuthorization:
278
278
  verification_uri_complete: str | None = None
279
279
 
280
280
 
281
+ @dataclass(frozen=True, slots=True)
282
+ class Pkce:
283
+ """One sign-in's PKCE pair: `challenge` goes in the authorization URL, `verifier` to the
284
+ code exchange. The verifier is left out of the repr, so logging the pair does not leak it.
285
+ """
286
+
287
+ verifier: str = field(repr=False)
288
+ challenge: str
289
+ method: Literal["S256"] = "S256"
290
+
291
+
281
292
  @dataclass(frozen=True, slots=True)
282
293
  class TokenResponse:
283
294
  """What a token exchange answers.
@@ -139,9 +139,26 @@ class OauthAdapter:
139
139
  def exchange_refresh_token(self, client_id: str, refresh_token: str, **kwargs: Any) -> Any:
140
140
  return self._call("exchange_refresh_token", client_id, refresh_token, **kwargs)
141
141
 
142
+ def exchange_authorization_code(
143
+ self, client_id: str, code: str, code_verifier: str, redirect_uri: str, **kwargs: Any
144
+ ) -> Any:
145
+ return self._call(
146
+ "exchange_authorization_code", client_id, code, code_verifier, redirect_uri, **kwargs
147
+ )
148
+
142
149
  def revoke(self, client_id: str, token: str, **kwargs: Any) -> Any:
143
150
  return self._call("revoke", client_id, token, **kwargs)
144
151
 
152
+ # These three make no request, so they are plain methods on both clients.
153
+ def authorization_url(self, *args: Any, **kwargs: Any) -> Any:
154
+ return self._client.oauth.authorization_url(*args, **kwargs)
155
+
156
+ def create_pkce(self) -> Any:
157
+ return self._client.oauth.create_pkce()
158
+
159
+ def pkce_challenge(self, verifier: str) -> Any:
160
+ return self._client.oauth.pkce_challenge(verifier)
161
+
145
162
  def poll_device_token(self, client_id: str, device: Any, **kwargs: Any) -> Any:
146
163
  return self._call("poll_device_token", client_id, device, **kwargs)
147
164
 
@@ -1,6 +1,6 @@
1
1
  """The `oauth` accessor against the shared corpus's oauth section, on both clients.
2
2
 
3
- Nothing here reads `oauth.deferred`: those operations are not in this release. Every call
3
+ The authorization code flow's vectors, under `oauth.deferred`, are read here too. Every call
4
4
  under test runs through `settle`, so a loop that never ends fails its test instead of
5
5
  hanging the suite.
6
6
  """
@@ -8,6 +8,7 @@ hanging the suite.
8
8
  from __future__ import annotations
9
9
 
10
10
  import asyncio
11
+ import re
11
12
  import time
12
13
  import urllib.parse
13
14
  from collections.abc import Iterator
@@ -82,7 +83,9 @@ def test_no_oauth_request_carries_the_api_key(make_client: ClientFactory) -> Non
82
83
  outcome = settle(lambda: _every_operation(client.oauth), bound)
83
84
 
84
85
  assert outcome is None, f"settled with {outcome!r}"
85
- assert len(stub.requests) == 6
86
+ assert len(stub.requests) == 7
87
+ url = client.oauth.authorization_url("internetdata-cli", "http://127.0.0.1/cb", "c" * 43)
88
+ assert key not in url, "the authorization URL carried the API key"
86
89
  for req in stub.requests:
87
90
  label = f"{req.method} {req.url.path}"
88
91
  for name in case["forbiddenHeaders"]:
@@ -98,7 +101,9 @@ def test_no_oauth_request_carries_the_api_key(make_client: ClientFactory) -> Non
98
101
  assert stub.requests[-1].headers["authorization"] == f"Bearer {key}"
99
102
 
100
103
 
101
- @pytest.mark.parametrize("case", CORPUS["forms"]["cases"], ids=lambda case: case["name"])
104
+ @pytest.mark.parametrize(
105
+ "case", CORPUS["forms"]["cases"] + CORPUS["deferred"]["forms"], ids=lambda case: case["name"]
106
+ )
102
107
  def test_each_operation_requests_its_endpoint_with_exactly_its_form_fields(
103
108
  make_client: ClientFactory, case: dict[str, Any]
104
109
  ) -> None:
@@ -244,7 +249,11 @@ def test_a_failed_answer_is_an_oauth_refusal_only_when_it_is_one(
244
249
  _assert_outcome(outcome, case["expect"], case["name"])
245
250
 
246
251
 
247
- @pytest.mark.parametrize("case", CORPUS["retries"]["cases"], ids=lambda case: case["name"])
252
+ @pytest.mark.parametrize(
253
+ "case",
254
+ CORPUS["retries"]["cases"] + CORPUS["deferred"]["retries"],
255
+ ids=lambda case: case["name"],
256
+ )
248
257
  def test_only_what_consumes_nothing_is_retried(
249
258
  make_client: ClientFactory, case: dict[str, Any]
250
259
  ) -> None:
@@ -319,11 +328,68 @@ def test_cancelling_a_poll_during_its_first_wait_ends_it_at_once() -> None:
319
328
  assert stub.requests == [], "no request after the cancel"
320
329
 
321
330
 
331
+ @pytest.mark.parametrize(
332
+ "case", CORPUS["deferred"]["authorizationUrl"], ids=lambda case: case["name"]
333
+ )
334
+ def test_an_authorization_url_is_built_exactly_as_the_corpus_spells_it(
335
+ make_client: ClientFactory, case: dict[str, Any]
336
+ ) -> None:
337
+ stub = OauthStub([{"status": 200, "body": EVERY_REQUIRED_MEMBER}], LoopBound())
338
+ client = make_client(base_url=case["baseUrl"], transport=stub.transport)
339
+
340
+ url = client.oauth.authorization_url(
341
+ case["clientId"],
342
+ case["redirectUri"],
343
+ case["codeChallenge"],
344
+ scope=case.get("scope"),
345
+ state=case.get("state"),
346
+ resource=case.get("resource"),
347
+ )
348
+
349
+ assert url == case["expect"]
350
+ assert stub.requests == [], "requests sent"
351
+
352
+
353
+ def test_an_authorization_url_leaves_out_an_empty_option_and_refuses_an_empty_value(
354
+ make_client: ClientFactory,
355
+ ) -> None:
356
+ case = CORPUS["deferred"]["authorizationUrl"][0]
357
+ oauth = make_client(base_url=BASE_URL).oauth
358
+ args = (case["clientId"], case["redirectUri"], case["codeChallenge"])
359
+
360
+ url = oauth.authorization_url(*args, scope="", state="", resource="")
361
+
362
+ assert url == case["expect"].replace(case["baseUrl"], BASE_URL)
363
+ with pytest.raises(ValueError, match="client_id"):
364
+ oauth.authorization_url("", case["redirectUri"], case["codeChallenge"])
365
+ with pytest.raises(ValueError, match="code_challenge"):
366
+ oauth.authorization_url(case["clientId"], case["redirectUri"], "\ud800")
367
+
368
+
369
+ def test_a_pkce_pair_is_fresh_and_its_challenge_is_the_s256_one(
370
+ make_client: ClientFactory,
371
+ ) -> None:
372
+ pkce = CORPUS["deferred"]["pkce"]
373
+ oauth = make_client().oauth
374
+
375
+ first = oauth.create_pkce()
376
+
377
+ assert oauth.pkce_challenge(pkce["verifier"]) == pkce["challenge"]
378
+ assert re.fullmatch(pkce["generatedVerifierPattern"], first.verifier)
379
+ assert first.challenge == oauth.pkce_challenge(first.verifier)
380
+ assert first.method == pkce["method"]
381
+ assert oauth.create_pkce().verifier != first.verifier, "two pairs share a verifier"
382
+ assert first.verifier not in repr(first)
383
+
384
+
322
385
  def _every_operation(oauth: OauthAdapter) -> None:
323
386
  oauth.metadata()
324
387
  oauth.device_authorization("internetdata-cli", scope="account.read")
325
388
  oauth.exchange_device_code("internetdata-cli", "mo_dc_x")
326
389
  oauth.exchange_refresh_token("internetdata-cli", "mo_rt_x")
390
+ oauth.exchange_authorization_code(
391
+ "internetdata-cli", "mo_ac_x", "v" * 43, "http://127.0.0.1/cb"
392
+ )
327
393
  oauth.revoke("internetdata-cli", "mo_rt_x")
328
394
  oauth.poll_device_token("internetdata-cli", DEVICE)
329
395
 
@@ -348,6 +414,10 @@ def _call(oauth: OauthAdapter, operation: str, args: dict[str, str]) -> Any:
348
414
  return oauth.exchange_device_code(args["clientId"], args["deviceCode"])
349
415
  if operation == "exchangeRefreshToken":
350
416
  return oauth.exchange_refresh_token(args["clientId"], args["refreshToken"])
417
+ if operation == "exchangeAuthorizationCode":
418
+ return oauth.exchange_authorization_code(
419
+ args["clientId"], args["code"], args["codeVerifier"], args["redirectUri"]
420
+ )
351
421
  if operation == "revoke":
352
422
  return oauth.revoke(args["clientId"], args["token"])
353
423
  raise AssertionError(f"the corpus names an operation this suite does not know: {operation}")
File without changes
File without changes