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.
- {internetdata-2.5.0 → internetdata-2.6.0}/PKG-INFO +21 -1
- {internetdata-2.5.0 → internetdata-2.6.0}/README.md +20 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/__init__.py +3 -1
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_core.py +58 -1
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/aio.py +53 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/client.py +59 -1
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/models.py +11 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/tests/helpers.py +17 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/tests/test_oauth.py +74 -4
- {internetdata-2.5.0 → internetdata-2.6.0}/.gitignore +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/LICENSE +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/pyproject.toml +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/scripts/download-spec.sh +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/scripts/generate.sh +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/scripts/publish.sh +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/scripts/v2_subset.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/spec/openapi.yaml +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/account/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/account/account_identity.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/account_create_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/account_list_apikeys.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/account_reveal_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/account_revoke_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/account_rotate_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/oauth_authorize.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/oauth_device_authorization.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/oauth_metadata.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/oauth_revoke.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/oauth_token.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/organization/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/organization/account_org.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/organization/account_org_members.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/client.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/errors.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_create_apikey_request.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_created_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_org.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_org_ref.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_org_wrap.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_rc.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_revealed_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_user.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/apikey_detail.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/apikey_list.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_format.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_sample_size.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_version.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/db_checksums.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/device_authorization.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/device_authorization_request.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/download.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/download_outcome.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/error.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/identity.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/oauth_authorize_code_challenge_method.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/oauth_authorize_response_type.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/oauth_error.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/oauth_metadata.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/oauth_revoke_response_200.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/revoke_request.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/standing.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/token_request.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/token_response.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/types.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/errors.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/py.typed +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/testdata/testdata.json +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/tests/conftest.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/tests/test_client.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.0}/tests/test_conformance.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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) ==
|
|
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(
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/account/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/api_keys/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/authorization/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/database_v_2/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/api/organization/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_org_ref.py
RENAMED
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_org_wrap.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/account_user.py
RENAMED
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/apikey_detail.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_format.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/database_version.py
RENAMED
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/db_checksums.py
RENAMED
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/device_authorization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/download_outcome.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/oauth_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/revoke_request.py
RENAMED
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/token_request.py
RENAMED
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.0}/src/internetdata/_generated/models/token_response.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|