cliV1 0.0.8__py3-none-any.whl
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.
- cliv1-0.0.8.dist-info/METADATA +168 -0
- cliv1-0.0.8.dist-info/RECORD +87 -0
- cliv1-0.0.8.dist-info/WHEEL +4 -0
- cliv1-0.0.8.dist-info/licenses/LICENSE +21 -0
- swagger_petstore_open_api_3_0/__init__.py +15 -0
- swagger_petstore_open_api_3_0/apis/__init__.py +5 -0
- swagger_petstore_open_api_3_0/apis/pet_api.py +883 -0
- swagger_petstore_open_api_3_0/apis/store.py +413 -0
- swagger_petstore_open_api_3_0/apis/user_api.py +794 -0
- swagger_petstore_open_api_3_0/async_client.py +79 -0
- swagger_petstore_open_api_3_0/auth.py +17 -0
- swagger_petstore_open_api_3_0/base_client.py +18 -0
- swagger_petstore_open_api_3_0/client.py +77 -0
- swagger_petstore_open_api_3_0/core/__init__.py +358 -0
- swagger_petstore_open_api_3_0/core/_internal/__init__.py +9 -0
- swagger_petstore_open_api_3_0/core/_internal/content_disposition.py +154 -0
- swagger_petstore_open_api_3_0/core/_internal/flattening.py +139 -0
- swagger_petstore_open_api_3_0/core/_internal/headers.py +144 -0
- swagger_petstore_open_api_3_0/core/_internal/sse_parser.py +124 -0
- swagger_petstore_open_api_3_0/core/_internal/subscripts.py +50 -0
- swagger_petstore_open_api_3_0/core/_internal/urls.py +186 -0
- swagger_petstore_open_api_3_0/core/_internal/wire.py +198 -0
- swagger_petstore_open_api_3_0/core/adapters.py +177 -0
- swagger_petstore_open_api_3_0/core/auth/__init__.py +70 -0
- swagger_petstore_open_api_3_0/core/auth/composition.py +175 -0
- swagger_petstore_open_api_3_0/core/auth/models.py +216 -0
- swagger_petstore_open_api_3_0/core/auth/protocols.py +103 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/__init__.py +94 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/api_key_auth.py +53 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/basic_auth.py +73 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/bearer_auth.py +21 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_authorization_code.py +393 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_client_credentials.py +127 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_password.py +142 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_schemes.py +300 -0
- swagger_petstore_open_api_3_0/core/base_raw_response.py +49 -0
- swagger_petstore_open_api_3_0/core/bodies.py +550 -0
- swagger_petstore_open_api_3_0/core/converters/__init__.py +68 -0
- swagger_petstore_open_api_3_0/core/converters/date_time.py +237 -0
- swagger_petstore_open_api_3_0/core/converters/encoded_bytes.py +273 -0
- swagger_petstore_open_api_3_0/core/converters/open_enum.py +35 -0
- swagger_petstore_open_api_3_0/core/decoding.py +498 -0
- swagger_petstore_open_api_3_0/core/discriminators.py +129 -0
- swagger_petstore_open_api_3_0/core/event_streams.py +225 -0
- swagger_petstore_open_api_3_0/core/exceptions.py +38 -0
- swagger_petstore_open_api_3_0/core/file_responses.py +335 -0
- swagger_petstore_open_api_3_0/core/files.py +173 -0
- swagger_petstore_open_api_3_0/core/httpx_transport.py +748 -0
- swagger_petstore_open_api_3_0/core/models.py +143 -0
- swagger_petstore_open_api_3_0/core/optionality.py +130 -0
- swagger_petstore_open_api_3_0/core/params.py +157 -0
- swagger_petstore_open_api_3_0/core/raw_client.py +338 -0
- swagger_petstore_open_api_3_0/core/request_options.py +94 -0
- swagger_petstore_open_api_3_0/core/results.py +123 -0
- swagger_petstore_open_api_3_0/core/runtime_env.py +43 -0
- swagger_petstore_open_api_3_0/core/servers.py +33 -0
- swagger_petstore_open_api_3_0/core/transport.py +191 -0
- swagger_petstore_open_api_3_0/errors/__init__.py +48 -0
- swagger_petstore_open_api_3_0/errors/add_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_order_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/find_pets_by_status_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/find_pets_by_tags_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_order_by_id_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_pet_by_id_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_user_by_name_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/login_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/place_order_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_pet_with_form_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/upload_file_error.py +21 -0
- swagger_petstore_open_api_3_0/models/__init__.py +23 -0
- swagger_petstore_open_api_3_0/models/api_response.py +18 -0
- swagger_petstore_open_api_3_0/models/category.py +15 -0
- swagger_petstore_open_api_3_0/models/enums/__init__.py +4 -0
- swagger_petstore_open_api_3_0/models/enums/order_status.py +17 -0
- swagger_petstore_open_api_3_0/models/enums/pet_status.py +17 -0
- swagger_petstore_open_api_3_0/models/order.py +27 -0
- swagger_petstore_open_api_3_0/models/pet.py +28 -0
- swagger_petstore_open_api_3_0/models/tag_model.py +15 -0
- swagger_petstore_open_api_3_0/models/user.py +29 -0
- swagger_petstore_open_api_3_0/py.typed +0 -0
- swagger_petstore_open_api_3_0/server/__init__.py +21 -0
- swagger_petstore_open_api_3_0/server/server.py +17 -0
- swagger_petstore_open_api_3_0/server/server_config.py +52 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""``http`` / ``basic`` -- RFC 7617."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from base64 import b64encode
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TypeAlias
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
10
|
+
from typing_extensions import TypedDict
|
|
11
|
+
|
|
12
|
+
from ...params import param
|
|
13
|
+
from ..models import AuthParams
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BasicAuthCredentials(BaseModel):
|
|
17
|
+
"""The user-id and password of an ``http``/``basic`` scheme (RFC 7617)."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
20
|
+
|
|
21
|
+
username: str
|
|
22
|
+
password: str = Field(repr=False)
|
|
23
|
+
|
|
24
|
+
@field_validator("username")
|
|
25
|
+
@classmethod
|
|
26
|
+
def _reject_colon(cls, value: str) -> str:
|
|
27
|
+
if ":" in value:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
"username must not contain ':' -- RFC 7617 encodes 'user-id:password', so a colon "
|
|
30
|
+
f"in the user-id cannot be decoded unambiguously; got {value!r}"
|
|
31
|
+
)
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def coerce(cls, value: BasicAuthCredentialsOrDict) -> BasicAuthCredentials:
|
|
36
|
+
"""Accept either spelling, validating the mapping form.
|
|
37
|
+
|
|
38
|
+
Unlike the other coercion seams in this runtime, no credential model's ``coerce`` takes
|
|
39
|
+
``None``: an absent credential means *no scheme*, which the client decides before this.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
value: The credential, as the typed model or as a mapping.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The validated model, passed straight through when it already is one.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValidationError: If the mapping is missing a key, carries an unknown one, or holds a
|
|
49
|
+
user-id with a colon. It subclasses ``ValueError``."""
|
|
50
|
+
if isinstance(value, cls):
|
|
51
|
+
return value
|
|
52
|
+
return cls.model_validate(value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BasicAuthCredentialsDict(TypedDict):
|
|
56
|
+
username: str
|
|
57
|
+
password: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
BasicAuthCredentialsOrDict: TypeAlias = BasicAuthCredentials | BasicAuthCredentialsDict
|
|
61
|
+
"""What a Basic credential accepts: the typed model, or a mapping carrying the same two keys."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class BasicAuthScheme:
|
|
66
|
+
"""``http``/``basic``: ``Authorization: Basic base64(user-id:password)``."""
|
|
67
|
+
|
|
68
|
+
credentials: BasicAuthCredentials
|
|
69
|
+
|
|
70
|
+
def apply(self) -> AuthParams:
|
|
71
|
+
raw = f"{self.credentials.username}:{self.credentials.password}"
|
|
72
|
+
encoded = b64encode(raw.encode()).decode()
|
|
73
|
+
return AuthParams(headers=(param[str]("Authorization", f"Basic {encoded}"),))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""``http`` / ``bearer`` -- and, on the wire, ``openIdConnect`` too."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from ...params import param
|
|
8
|
+
from ..models import AuthParams
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class BearerAuthScheme:
|
|
13
|
+
"""``http``/``bearer``: ``Authorization: Bearer <token>``.
|
|
14
|
+
|
|
15
|
+
Also the wire form of an ``openIdConnect`` scheme, whose discovery document says how a token is
|
|
16
|
+
obtained but not how it is sent."""
|
|
17
|
+
|
|
18
|
+
token: str = field(repr=False)
|
|
19
|
+
|
|
20
|
+
def apply(self) -> AuthParams:
|
|
21
|
+
return AuthParams(headers=(param[str]("Authorization", f"Bearer {self.token}"),))
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""``oauth2`` / ``authorizationCode`` -- RFC 6749 §4.1, with RFC 7636 PKCE.
|
|
2
|
+
|
|
3
|
+
The only grant that cannot complete without a human, and the only one needing the refreshable tier.
|
|
4
|
+
|
|
5
|
+
**The human leg is a callback on the credentials**, not a two-step surface on the client: the source
|
|
6
|
+
calls it in the middle of ``fetch`` -- build the URL, hand it over, exchange the code that comes
|
|
7
|
+
back -- so the whole flow stays behind the one ``fetch(credentials)`` seam every grant shares.
|
|
8
|
+
|
|
9
|
+
**The credentials are twinned** because that callback's flavour must match the transport awaiting
|
|
10
|
+
it; a prompt blocking on a human is the last thing that should run inside an event loop."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import secrets
|
|
15
|
+
from base64 import urlsafe_b64encode
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from hashlib import sha256
|
|
19
|
+
from typing import Generic, Literal, TypeAlias
|
|
20
|
+
|
|
21
|
+
from pydantic import ConfigDict, Field, model_validator
|
|
22
|
+
from typing_extensions import NotRequired, Self, TypedDict
|
|
23
|
+
|
|
24
|
+
from ..._internal.urls import build_url
|
|
25
|
+
from ...bodies import form_body
|
|
26
|
+
from ...decoding import async_json_decoder, json_decoder
|
|
27
|
+
from ...params import UrlTemplate, param
|
|
28
|
+
from ...raw_client import AsyncRawClient, RawClient
|
|
29
|
+
from ...results import Success
|
|
30
|
+
from ..models import (
|
|
31
|
+
CredentialsPlacement,
|
|
32
|
+
OAuth2Credentials,
|
|
33
|
+
OAuthParams,
|
|
34
|
+
OAuthTokenRefreshable,
|
|
35
|
+
ScopeT,
|
|
36
|
+
oauth_error_response,
|
|
37
|
+
scope_params,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
AuthorizationCodePrompt: TypeAlias = Callable[[str], str]
|
|
41
|
+
"""How the SDK obtains an authorization code: hand a URL to a human, get a code back.
|
|
42
|
+
|
|
43
|
+
Given the fully built authorization URL, an implementation navigates the user to it, receives the
|
|
44
|
+
redirect, and returns its ``code`` query parameter. Opening a browser and listening on the redirect
|
|
45
|
+
URI are the caller's business: what is correct differs between a desktop app, a CLI and a test.
|
|
46
|
+
|
|
47
|
+
Called on the first acquisition, and again whenever a refresh is refused. ⚠️ It runs **inside the
|
|
48
|
+
scheme's lock**, so it must not issue a request that itself uses this scheme."""
|
|
49
|
+
|
|
50
|
+
AsyncAuthorizationCodePrompt: TypeAlias = Callable[[str], Awaitable[str]]
|
|
51
|
+
""":data:`AuthorizationCodePrompt` for the asynchronous client: awaited rather than called.
|
|
52
|
+
|
|
53
|
+
A coroutine and not a union, so a synchronous prompt is a **type** error on the asynchronous
|
|
54
|
+
credentials rather than a whole event loop discovered blocked on a human in production."""
|
|
55
|
+
|
|
56
|
+
PkceMethod: TypeAlias = Literal["S256", "plain"]
|
|
57
|
+
"""RFC 7636 §4.2's code-challenge transformations.
|
|
58
|
+
|
|
59
|
+
A ``Literal`` and **not** a ``(str, Enum)``: these two members are fixed by an RFC, not derived from
|
|
60
|
+
a description, so the set cannot grow after generation and a call site is checked statically."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class _Pkce:
|
|
65
|
+
"""One authorization's verifier, its derived challenge, and which transformation derived it."""
|
|
66
|
+
|
|
67
|
+
verifier: str
|
|
68
|
+
challenge: str
|
|
69
|
+
method: PkceMethod
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _generate_pkce(method: PkceMethod) -> _Pkce:
|
|
73
|
+
"""A fresh RFC 7636 §4.1 verifier and its §4.2 challenge -- one pair per authorization.
|
|
74
|
+
|
|
75
|
+
Per acquisition rather than per client, because a verifier reused across authorizations is one
|
|
76
|
+
an observer of the first can replay against the second.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
method: ``S256``, or ``plain`` where §7.2 permits it -- the verifier as its own challenge.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The verifier, its challenge, and the method that derived it."""
|
|
83
|
+
verifier = secrets.token_urlsafe(32)
|
|
84
|
+
if method == "plain":
|
|
85
|
+
return _Pkce(verifier, verifier, method)
|
|
86
|
+
digest = sha256(verifier.encode("ascii")).digest()
|
|
87
|
+
return _Pkce(verifier, urlsafe_b64encode(digest).rstrip(b"=").decode("ascii"), method)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AuthorizationCodeCredentials(OAuth2Credentials, Generic[ScopeT]):
|
|
91
|
+
"""What the authorization-code grant needs, for the synchronous client (RFC 6749 §4.1).
|
|
92
|
+
|
|
93
|
+
``prompt_for_authorization_code`` is **required**, so a configured credential provably has a way
|
|
94
|
+
to obtain a code, checked when this model is built rather than at the first request.
|
|
95
|
+
|
|
96
|
+
``state`` is **pass-through only**: sent on the authorization request, never generated or
|
|
97
|
+
validated here -- the SDK never sees the redirect, so producing and verifying it are the
|
|
98
|
+
caller's one job. ``pkce`` defaults to ``"S256"``; ``None`` disables it and then requires
|
|
99
|
+
``client_secret``, which the validator below refuses at construction. ``scopes`` is typed by
|
|
100
|
+
the flow's declared vocabulary, as every grant's is."""
|
|
101
|
+
|
|
102
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
103
|
+
|
|
104
|
+
client_id: str
|
|
105
|
+
client_secret: str | None = Field(default=None, repr=False)
|
|
106
|
+
redirect_uri: str
|
|
107
|
+
scopes: list[ScopeT] | None = None
|
|
108
|
+
state: str | None = None
|
|
109
|
+
pkce: PkceMethod | None = "S256"
|
|
110
|
+
prompt_for_authorization_code: AuthorizationCodePrompt
|
|
111
|
+
|
|
112
|
+
@model_validator(mode="after")
|
|
113
|
+
def _require_a_secret_without_pkce(self) -> Self:
|
|
114
|
+
if self.pkce is None and self.client_secret is None:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"client_secret is required when pkce is None: a client that neither authenticates "
|
|
117
|
+
"nor proves possession of a code verifier cannot use the authorization-code grant "
|
|
118
|
+
'safely. Set pkce="S256" for a public client, or supply client_secret.'
|
|
119
|
+
)
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def coerce(cls, value: AuthorizationCodeCredentialsOrDict[ScopeT]) -> AuthorizationCodeCredentials[ScopeT]:
|
|
124
|
+
"""Accept either spelling, validating the mapping form.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
value: The credential, as the typed model or as a mapping.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The validated model; see :meth:`BasicAuthCredentials.coerce` for why it takes no
|
|
131
|
+
``None``.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ValidationError: If a key is missing or unknown, if ``scopes`` names one this flow does
|
|
135
|
+
not declare, or if ``pkce`` is ``None`` without a ``client_secret``."""
|
|
136
|
+
if isinstance(value, cls):
|
|
137
|
+
return value
|
|
138
|
+
return cls.model_validate(value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AuthorizationCodeCredentialsDict(TypedDict, Generic[ScopeT]):
|
|
142
|
+
client_id: str
|
|
143
|
+
client_secret: NotRequired[str | None]
|
|
144
|
+
redirect_uri: str
|
|
145
|
+
scopes: NotRequired[list[ScopeT] | None]
|
|
146
|
+
state: NotRequired[str | None]
|
|
147
|
+
pkce: NotRequired[PkceMethod | None]
|
|
148
|
+
prompt_for_authorization_code: AuthorizationCodePrompt
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
AuthorizationCodeCredentialsOrDict: TypeAlias = (
|
|
152
|
+
AuthorizationCodeCredentials[ScopeT] | AuthorizationCodeCredentialsDict[ScopeT]
|
|
153
|
+
)
|
|
154
|
+
"""What an authorization-code credential accepts: the typed model, or a mapping of the same keys."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class AsyncAuthorizationCodeCredentials(OAuth2Credentials, Generic[ScopeT]):
|
|
158
|
+
""":class:`AuthorizationCodeCredentials` for the asynchronous client.
|
|
159
|
+
|
|
160
|
+
Identical but for ``prompt_for_authorization_code``, which is awaited. Written out rather than
|
|
161
|
+
sharing a base: the two would still need their own ``coerce``, ``TypedDict`` and validator."""
|
|
162
|
+
|
|
163
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
164
|
+
|
|
165
|
+
client_id: str
|
|
166
|
+
client_secret: str | None = Field(default=None, repr=False)
|
|
167
|
+
redirect_uri: str
|
|
168
|
+
scopes: list[ScopeT] | None = None
|
|
169
|
+
state: str | None = None
|
|
170
|
+
pkce: PkceMethod | None = "S256"
|
|
171
|
+
prompt_for_authorization_code: AsyncAuthorizationCodePrompt
|
|
172
|
+
|
|
173
|
+
@model_validator(mode="after")
|
|
174
|
+
def _require_a_secret_without_pkce(self) -> Self:
|
|
175
|
+
if self.pkce is None and self.client_secret is None:
|
|
176
|
+
raise ValueError(
|
|
177
|
+
"client_secret is required when pkce is None: a client that neither authenticates "
|
|
178
|
+
"nor proves possession of a code verifier cannot use the authorization-code grant "
|
|
179
|
+
'safely. Set pkce="S256" for a public client, or supply client_secret.'
|
|
180
|
+
)
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def coerce(
|
|
185
|
+
cls, value: AsyncAuthorizationCodeCredentialsOrDict[ScopeT]
|
|
186
|
+
) -> AsyncAuthorizationCodeCredentials[ScopeT]:
|
|
187
|
+
"""See :meth:`AuthorizationCodeCredentials.coerce`.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
value: The credential, as the typed model or as a mapping.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The validated model.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
ValidationError: If a key is missing or unknown, if ``scopes`` names one this flow does
|
|
197
|
+
not declare, or if ``pkce`` is ``None`` without a ``client_secret``."""
|
|
198
|
+
if isinstance(value, cls):
|
|
199
|
+
return value
|
|
200
|
+
return cls.model_validate(value)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class AsyncAuthorizationCodeCredentialsDict(TypedDict, Generic[ScopeT]):
|
|
204
|
+
client_id: str
|
|
205
|
+
client_secret: NotRequired[str | None]
|
|
206
|
+
redirect_uri: str
|
|
207
|
+
scopes: NotRequired[list[ScopeT] | None]
|
|
208
|
+
state: NotRequired[str | None]
|
|
209
|
+
pkce: NotRequired[PkceMethod | None]
|
|
210
|
+
prompt_for_authorization_code: AsyncAuthorizationCodePrompt
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
AsyncAuthorizationCodeCredentialsOrDict: TypeAlias = (
|
|
214
|
+
AsyncAuthorizationCodeCredentials[ScopeT] | AsyncAuthorizationCodeCredentialsDict[ScopeT]
|
|
215
|
+
)
|
|
216
|
+
""":data:`AuthorizationCodeCredentialsOrDict` for the asynchronous client."""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _authorization_params(
|
|
220
|
+
credentials: AuthorizationCodeCredentials[ScopeT] | AsyncAuthorizationCodeCredentials[ScopeT],
|
|
221
|
+
pkce: _Pkce | None,
|
|
222
|
+
) -> OAuthParams:
|
|
223
|
+
"""RFC 6749 §4.1.1's authorization request, as query parameters.
|
|
224
|
+
|
|
225
|
+
Shared by both flavours: what goes on an authorization URL does not depend on which transport
|
|
226
|
+
later exchanges the code.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
credentials: The configured credentials, in either flavour.
|
|
230
|
+
pkce: This acquisition's PKCE pair, or ``None`` when PKCE is disabled.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
The query parameters for the authorization URL. ``state`` is omitted entirely when unset
|
|
234
|
+
rather than sent empty, since §4.1.1 only RECOMMENDS it."""
|
|
235
|
+
params = (
|
|
236
|
+
param[str]("response_type", "code"),
|
|
237
|
+
param[str]("client_id", credentials.client_id),
|
|
238
|
+
param[str]("redirect_uri", credentials.redirect_uri),
|
|
239
|
+
*scope_params(credentials.scopes),
|
|
240
|
+
)
|
|
241
|
+
if credentials.state is not None:
|
|
242
|
+
params = (*params, param[str]("state", credentials.state))
|
|
243
|
+
if pkce is None:
|
|
244
|
+
return params
|
|
245
|
+
return (
|
|
246
|
+
*params,
|
|
247
|
+
param[str]("code_challenge", pkce.challenge),
|
|
248
|
+
param[PkceMethod]("code_challenge_method", pkce.method),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _code_verifier_params(pkce: _Pkce | None) -> OAuthParams:
|
|
253
|
+
"""RFC 7636 §4.5's ``code_verifier`` on the exchange.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
pkce: This acquisition's PKCE pair, or ``None`` when PKCE is disabled.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
One ``code_verifier`` parameter, or an empty tuple."""
|
|
260
|
+
if pkce is None:
|
|
261
|
+
return ()
|
|
262
|
+
return (param[str]("code_verifier", pkce.verifier),)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
266
|
+
class AuthorizationCodeTokenSource(Generic[ScopeT]):
|
|
267
|
+
"""The built-in :class:`RefreshableTokenSource` for the authorization-code grant.
|
|
268
|
+
|
|
269
|
+
Two requests with a human between them: :meth:`fetch` builds the authorization URL, hands it to
|
|
270
|
+
the credentials' prompt and exchanges the code; :meth:`refresh` skips both, which is the entire
|
|
271
|
+
reason this grant needs the refreshable tier.
|
|
272
|
+
|
|
273
|
+
``refresh_url`` is its own field because the flow declares ``refreshUrl`` beside ``tokenUrl``;
|
|
274
|
+
§6 defaults it to the token endpoint, but a description naming another is read, not guessed."""
|
|
275
|
+
|
|
276
|
+
client: RawClient
|
|
277
|
+
authorization_url: UrlTemplate
|
|
278
|
+
token_url: UrlTemplate
|
|
279
|
+
refresh_url: UrlTemplate
|
|
280
|
+
placement: CredentialsPlacement
|
|
281
|
+
|
|
282
|
+
def fetch(self, credentials: AuthorizationCodeCredentials[ScopeT]) -> OAuthTokenRefreshable:
|
|
283
|
+
pkce = _generate_pkce(credentials.pkce) if credentials.pkce is not None else None
|
|
284
|
+
code = credentials.prompt_for_authorization_code(
|
|
285
|
+
build_url(self.authorization_url, query_params=_authorization_params(credentials, pkce))
|
|
286
|
+
)
|
|
287
|
+
headers, client_params = self.placement(credentials.client_id, credentials.client_secret)
|
|
288
|
+
return self.client.execute(
|
|
289
|
+
http_method="POST",
|
|
290
|
+
url_template=self.token_url,
|
|
291
|
+
headers=headers,
|
|
292
|
+
body=form_body(
|
|
293
|
+
param[str]("grant_type", "authorization_code"),
|
|
294
|
+
param[str]("code", code),
|
|
295
|
+
param[str]("redirect_uri", credentials.redirect_uri),
|
|
296
|
+
*_code_verifier_params(pkce),
|
|
297
|
+
*client_params,
|
|
298
|
+
),
|
|
299
|
+
decoder=json_decoder[OAuthTokenRefreshable],
|
|
300
|
+
error_mapper=oauth_error_response,
|
|
301
|
+
).unwrap()
|
|
302
|
+
|
|
303
|
+
def refresh(
|
|
304
|
+
self, credentials: AuthorizationCodeCredentials[ScopeT], refresh_token: str
|
|
305
|
+
) -> OAuthTokenRefreshable | None:
|
|
306
|
+
"""RFC 6749 §6, and ``None`` when the provider refuses.
|
|
307
|
+
|
|
308
|
+
The one operation in this runtime that reads an ``ApiResult`` instead of unwrapping it: a
|
|
309
|
+
refused refresh is a step in the flow, not an error, so the scheme re-acquires instead.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
credentials: The configured credentials.
|
|
313
|
+
refresh_token: The refresh token to spend.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
The new token, or ``None`` for a provider refusal. A transport failure propagates, as
|
|
317
|
+
does a success whose body will not decode."""
|
|
318
|
+
headers, client_params = self.placement(credentials.client_id, credentials.client_secret)
|
|
319
|
+
result = self.client.execute(
|
|
320
|
+
http_method="POST",
|
|
321
|
+
url_template=self.refresh_url,
|
|
322
|
+
headers=headers,
|
|
323
|
+
body=form_body(
|
|
324
|
+
param[str]("grant_type", "refresh_token"),
|
|
325
|
+
param[str]("refresh_token", refresh_token),
|
|
326
|
+
*client_params,
|
|
327
|
+
),
|
|
328
|
+
decoder=json_decoder[OAuthTokenRefreshable],
|
|
329
|
+
error_mapper=oauth_error_response,
|
|
330
|
+
)
|
|
331
|
+
return result.payload if isinstance(result, Success) else None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
335
|
+
class AsyncAuthorizationCodeTokenSource(Generic[ScopeT]):
|
|
336
|
+
""":class:`AuthorizationCodeTokenSource` for the asynchronous client.
|
|
337
|
+
|
|
338
|
+
The prompt is awaited, as are both requests."""
|
|
339
|
+
|
|
340
|
+
client: AsyncRawClient
|
|
341
|
+
authorization_url: UrlTemplate
|
|
342
|
+
token_url: UrlTemplate
|
|
343
|
+
refresh_url: UrlTemplate
|
|
344
|
+
placement: CredentialsPlacement
|
|
345
|
+
|
|
346
|
+
async def fetch(self, credentials: AsyncAuthorizationCodeCredentials[ScopeT]) -> OAuthTokenRefreshable:
|
|
347
|
+
pkce = _generate_pkce(credentials.pkce) if credentials.pkce is not None else None
|
|
348
|
+
code = await credentials.prompt_for_authorization_code(
|
|
349
|
+
build_url(self.authorization_url, query_params=_authorization_params(credentials, pkce))
|
|
350
|
+
)
|
|
351
|
+
headers, client_params = self.placement(credentials.client_id, credentials.client_secret)
|
|
352
|
+
return (
|
|
353
|
+
await self.client.execute(
|
|
354
|
+
http_method="POST",
|
|
355
|
+
url_template=self.token_url,
|
|
356
|
+
headers=headers,
|
|
357
|
+
body=form_body(
|
|
358
|
+
param[str]("grant_type", "authorization_code"),
|
|
359
|
+
param[str]("code", code),
|
|
360
|
+
param[str]("redirect_uri", credentials.redirect_uri),
|
|
361
|
+
*_code_verifier_params(pkce),
|
|
362
|
+
*client_params,
|
|
363
|
+
),
|
|
364
|
+
decoder=async_json_decoder[OAuthTokenRefreshable],
|
|
365
|
+
error_mapper=oauth_error_response,
|
|
366
|
+
)
|
|
367
|
+
).unwrap()
|
|
368
|
+
|
|
369
|
+
async def refresh(
|
|
370
|
+
self, credentials: AsyncAuthorizationCodeCredentials[ScopeT], refresh_token: str
|
|
371
|
+
) -> OAuthTokenRefreshable | None:
|
|
372
|
+
"""See :meth:`AuthorizationCodeTokenSource.refresh`.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
credentials: The configured credentials.
|
|
376
|
+
refresh_token: The refresh token to spend.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
The new token, or ``None`` for a provider refusal."""
|
|
380
|
+
headers, client_params = self.placement(credentials.client_id, credentials.client_secret)
|
|
381
|
+
result = await self.client.execute(
|
|
382
|
+
http_method="POST",
|
|
383
|
+
url_template=self.refresh_url,
|
|
384
|
+
headers=headers,
|
|
385
|
+
body=form_body(
|
|
386
|
+
param[str]("grant_type", "refresh_token"),
|
|
387
|
+
param[str]("refresh_token", refresh_token),
|
|
388
|
+
*client_params,
|
|
389
|
+
),
|
|
390
|
+
decoder=async_json_decoder[OAuthTokenRefreshable],
|
|
391
|
+
error_mapper=oauth_error_response,
|
|
392
|
+
)
|
|
393
|
+
return result.payload if isinstance(result, Success) else None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""``oauth2`` / ``clientCredentials`` -- RFC 6749 §4.4.
|
|
2
|
+
|
|
3
|
+
The grant with the least to it: no resource owner, so no second credential pair and no human. The
|
|
4
|
+
whole request is a ``grant_type``, the scopes, and whatever the placement contributes."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Generic, TypeAlias
|
|
10
|
+
|
|
11
|
+
from pydantic import ConfigDict, Field
|
|
12
|
+
from typing_extensions import NotRequired, TypedDict
|
|
13
|
+
|
|
14
|
+
from ...bodies import form_body
|
|
15
|
+
from ...decoding import async_json_decoder, json_decoder
|
|
16
|
+
from ...params import UrlTemplate, param
|
|
17
|
+
from ...raw_client import AsyncRawClient, RawClient
|
|
18
|
+
from ..models import (
|
|
19
|
+
CredentialsPlacement,
|
|
20
|
+
OAuth2Credentials,
|
|
21
|
+
OAuthParams,
|
|
22
|
+
OAuthToken,
|
|
23
|
+
ScopeT,
|
|
24
|
+
oauth_error_response,
|
|
25
|
+
scope_params,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ClientCredentials(OAuth2Credentials, Generic[ScopeT]):
|
|
30
|
+
"""The client identifier and secret of an OAuth 2.0 ``clientCredentials`` flow (RFC 6749 §4.4).
|
|
31
|
+
|
|
32
|
+
``scopes`` is a list this SDK joins for the wire, so a caller never writes §3.3's delimiter, and
|
|
33
|
+
it is typed by the flow's declared vocabulary -- so a scope the flow does not document fails the
|
|
34
|
+
build at the call site instead of the request at the provider."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
37
|
+
|
|
38
|
+
client_id: str
|
|
39
|
+
client_secret: str = Field(repr=False)
|
|
40
|
+
scopes: list[ScopeT] | None = None
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def coerce(cls, value: ClientCredentialsOrDict[ScopeT]) -> ClientCredentials[ScopeT]:
|
|
44
|
+
"""Accept either spelling, validating the mapping form.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
value: The credential, as the typed model or as a mapping.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The validated model; see :meth:`BasicAuthCredentials.coerce` for why it takes no
|
|
51
|
+
``None``.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValidationError: If the mapping is missing a key, carries an unknown one, or names a
|
|
55
|
+
scope this flow does not declare."""
|
|
56
|
+
if isinstance(value, cls):
|
|
57
|
+
return value
|
|
58
|
+
return cls.model_validate(value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ClientCredentialsDict(TypedDict, Generic[ScopeT]):
|
|
62
|
+
client_id: str
|
|
63
|
+
client_secret: str
|
|
64
|
+
scopes: NotRequired[list[ScopeT] | None]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
ClientCredentialsOrDict: TypeAlias = ClientCredentials[ScopeT] | ClientCredentialsDict[ScopeT]
|
|
68
|
+
"""What a client-credentials credential accepts: the typed model, or a mapping of the same keys."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _token_params(credentials: ClientCredentials[ScopeT]) -> OAuthParams:
|
|
72
|
+
"""RFC 6749 §4.4.2's token request, minus the client's own credentials.
|
|
73
|
+
|
|
74
|
+
Shared by both flavours rather than duplicated, so an edit cannot land in one only.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
credentials: The configured client credentials.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The ``grant_type`` and the scopes; the placement's half is spliced in by each ``fetch``."""
|
|
81
|
+
return param[str]("grant_type", "client_credentials"), *scope_params(credentials.scopes)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
85
|
+
class ClientCredentialsTokenSource(Generic[ScopeT]):
|
|
86
|
+
"""The built-in :class:`TokenSource` for the client-credentials grant: POST the token request.
|
|
87
|
+
|
|
88
|
+
``placement`` is a field rather than two subclasses, because the placements differ in *data* and
|
|
89
|
+
not in behaviour: :meth:`fetch` is identical for both. Carries no ``auth_scheme=`` -- the
|
|
90
|
+
credentials are on this request already, so the fetch provably cannot recurse into itself."""
|
|
91
|
+
|
|
92
|
+
client: RawClient
|
|
93
|
+
token_url: UrlTemplate
|
|
94
|
+
placement: CredentialsPlacement
|
|
95
|
+
|
|
96
|
+
def fetch(self, credentials: ClientCredentials[ScopeT]) -> OAuthToken:
|
|
97
|
+
headers, client_params = self.placement(credentials.client_id, credentials.client_secret)
|
|
98
|
+
return self.client.execute(
|
|
99
|
+
http_method="POST",
|
|
100
|
+
url_template=self.token_url,
|
|
101
|
+
headers=headers,
|
|
102
|
+
body=form_body(*_token_params(credentials), *client_params),
|
|
103
|
+
decoder=json_decoder[OAuthToken],
|
|
104
|
+
error_mapper=oauth_error_response,
|
|
105
|
+
).unwrap()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
109
|
+
class AsyncClientCredentialsTokenSource(Generic[ScopeT]):
|
|
110
|
+
""":class:`ClientCredentialsTokenSource` for the asynchronous client: the request is awaited."""
|
|
111
|
+
|
|
112
|
+
client: AsyncRawClient
|
|
113
|
+
token_url: UrlTemplate
|
|
114
|
+
placement: CredentialsPlacement
|
|
115
|
+
|
|
116
|
+
async def fetch(self, credentials: ClientCredentials[ScopeT]) -> OAuthToken:
|
|
117
|
+
headers, client_params = self.placement(credentials.client_id, credentials.client_secret)
|
|
118
|
+
return (
|
|
119
|
+
await self.client.execute(
|
|
120
|
+
http_method="POST",
|
|
121
|
+
url_template=self.token_url,
|
|
122
|
+
headers=headers,
|
|
123
|
+
body=form_body(*_token_params(credentials), *client_params),
|
|
124
|
+
decoder=async_json_decoder[OAuthToken],
|
|
125
|
+
error_mapper=oauth_error_response,
|
|
126
|
+
)
|
|
127
|
+
).unwrap()
|