okta-client-python 0.1.0__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.
- okta_client/__init__.py +20 -0
- okta_client/authfoundation/__init__.py +197 -0
- okta_client/authfoundation/authentication.py +247 -0
- okta_client/authfoundation/coalesced_result.py +113 -0
- okta_client/authfoundation/codable.py +72 -0
- okta_client/authfoundation/expires.py +49 -0
- okta_client/authfoundation/key_provider.py +130 -0
- okta_client/authfoundation/networking/__init__.py +56 -0
- okta_client/authfoundation/networking/body.py +46 -0
- okta_client/authfoundation/networking/client.py +200 -0
- okta_client/authfoundation/networking/types.py +293 -0
- okta_client/authfoundation/oauth2/__init__.py +104 -0
- okta_client/authfoundation/oauth2/claims.py +44 -0
- okta_client/authfoundation/oauth2/client.py +402 -0
- okta_client/authfoundation/oauth2/client_authorization.py +172 -0
- okta_client/authfoundation/oauth2/config.py +298 -0
- okta_client/authfoundation/oauth2/errors.py +32 -0
- okta_client/authfoundation/oauth2/jwt_bearer_claims.py +59 -0
- okta_client/authfoundation/oauth2/jwt_bearer_utils.py +30 -0
- okta_client/authfoundation/oauth2/jwt_context.py +52 -0
- okta_client/authfoundation/oauth2/jwt_token.py +214 -0
- okta_client/authfoundation/oauth2/models.py +198 -0
- okta_client/authfoundation/oauth2/parameters.py +36 -0
- okta_client/authfoundation/oauth2/refresh_token.py +165 -0
- okta_client/authfoundation/oauth2/request_protocols.py +174 -0
- okta_client/authfoundation/oauth2/requests/__init__.py +37 -0
- okta_client/authfoundation/oauth2/requests/introspect.py +50 -0
- okta_client/authfoundation/oauth2/requests/jwks.py +44 -0
- okta_client/authfoundation/oauth2/requests/oauth_authorization_server.py +44 -0
- okta_client/authfoundation/oauth2/requests/openid_configuration.py +47 -0
- okta_client/authfoundation/oauth2/requests/revoke.py +54 -0
- okta_client/authfoundation/oauth2/requests/user_info.py +37 -0
- okta_client/authfoundation/oauth2/utils.py +25 -0
- okta_client/authfoundation/oauth2/validation_protocols.py +33 -0
- okta_client/authfoundation/oauth2/validator_registry.py +64 -0
- okta_client/authfoundation/oauth2/validators/token_hash.py +37 -0
- okta_client/authfoundation/oauth2/validators/token_validator.py +26 -0
- okta_client/authfoundation/time_coordinator.py +57 -0
- okta_client/authfoundation/token.py +201 -0
- okta_client/authfoundation/user_agent.py +80 -0
- okta_client/authfoundation/utils.py +63 -0
- okta_client/browser_signin/__init__.py +11 -0
- okta_client/directauth/__init__.py +11 -0
- okta_client/oauth2auth/__init__.py +63 -0
- okta_client/oauth2auth/authorization_code.py +594 -0
- okta_client/oauth2auth/cross_app.py +626 -0
- okta_client/oauth2auth/jwt_bearer.py +182 -0
- okta_client/oauth2auth/resource_owner.py +159 -0
- okta_client/oauth2auth/token_exchange.py +380 -0
- okta_client/oauth2auth/utils.py +87 -0
- okta_client/py.typed +0 -0
- okta_client_python-0.1.0.dist-info/METADATA +936 -0
- okta_client_python-0.1.0.dist-info/RECORD +56 -0
- okta_client_python-0.1.0.dist-info/WHEEL +5 -0
- okta_client_python-0.1.0.dist-info/licenses/LICENSE +171 -0
- okta_client_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# The Okta software accompanied by this notice is provided pursuant to the following terms:
|
|
2
|
+
# Copyright © 2026-Present, Okta, Inc.
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the
|
|
4
|
+
# License.
|
|
5
|
+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
|
|
6
|
+
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
|
|
7
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
8
|
+
# See the License for the specific language governing permissions and limitations under the License.
|
|
9
|
+
# coding: utf-8
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
from okta_client.authfoundation.oauth2.requests.oauth_authorization_server import OAuthAuthorizationServerRequest
|
|
19
|
+
from okta_client.authfoundation.utils import coerce_optional_sequence, coerce_optional_str
|
|
20
|
+
|
|
21
|
+
from ..coalesced_result import CoalescedResult
|
|
22
|
+
from ..networking import APIClient, APIClientListener, APIResponse, NetworkInterface
|
|
23
|
+
from ..time_coordinator import get_time_coordinator
|
|
24
|
+
from ..user_agent import sdk_user_agent
|
|
25
|
+
from .client_authorization import ClientAuthorization
|
|
26
|
+
from .config import OAuth2ClientConfiguration
|
|
27
|
+
from .errors import OAuth2Error
|
|
28
|
+
from .jwt_context import JWTValidationContext
|
|
29
|
+
from .models import JWKS, OAuthAuthorizationServer, OpenIdConfiguration, TokenInfo, UserInfo
|
|
30
|
+
from .refresh_token import RefreshTokenFlow, RefreshTokenRequest
|
|
31
|
+
from .requests import (
|
|
32
|
+
IntrospectRequest,
|
|
33
|
+
JWKSRequest,
|
|
34
|
+
OAuth2TokenRequest,
|
|
35
|
+
OpenIDConfigurationRequest,
|
|
36
|
+
RevokeRequest,
|
|
37
|
+
UserInfoRequest,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from ..token import Token
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@runtime_checkable
|
|
45
|
+
class OAuth2ClientListener(APIClientListener, Protocol):
|
|
46
|
+
"""Listener for OAuth2-specific lifecycle events."""
|
|
47
|
+
|
|
48
|
+
def will_refresh_token(self, client: OAuth2Client, token: Token) -> None:
|
|
49
|
+
"""Called before a token refresh begins."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def did_refresh_token(
|
|
53
|
+
self,
|
|
54
|
+
client: OAuth2Client,
|
|
55
|
+
token: Token,
|
|
56
|
+
refreshed_token: Token | None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Called after a token refresh completes."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OAuth2Client(APIClient):
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
configuration: OAuth2ClientConfiguration,
|
|
66
|
+
network: NetworkInterface | None = None,
|
|
67
|
+
time_provider: Callable[[], float] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
super().__init__(configuration=configuration, network=network)
|
|
70
|
+
self.configuration = configuration
|
|
71
|
+
self._sdk_user_agent = sdk_user_agent()
|
|
72
|
+
self._time_provider = time_provider or time.time
|
|
73
|
+
self._openid_configuration_fetch = CoalescedResult[OpenIdConfiguration](
|
|
74
|
+
ttl=self.configuration.metadata_cache_ttl,
|
|
75
|
+
time_provider=self._time_provider,
|
|
76
|
+
)
|
|
77
|
+
self._oauth_server_metadata_fetch = CoalescedResult[OAuthAuthorizationServer](
|
|
78
|
+
ttl=self.configuration.metadata_cache_ttl,
|
|
79
|
+
time_provider=self._time_provider,
|
|
80
|
+
)
|
|
81
|
+
self._jwks_fetch = CoalescedResult[JWKS](
|
|
82
|
+
ttl=0,
|
|
83
|
+
time_provider=self._time_provider,
|
|
84
|
+
)
|
|
85
|
+
self._refresh_lock = asyncio.Lock()
|
|
86
|
+
self._refresh_actions: dict[tuple[str, tuple[str, ...] | None], CoalescedResult[Token]] = {}
|
|
87
|
+
|
|
88
|
+
def _build_headers(self, request: Any) -> Mapping[str, str]:
|
|
89
|
+
"""Append SDK metadata to the User-Agent header."""
|
|
90
|
+
headers = dict(super()._build_headers(request))
|
|
91
|
+
base_ua = headers.get("User-Agent", "")
|
|
92
|
+
headers["User-Agent"] = f"{base_ua} {self._sdk_user_agent}".strip()
|
|
93
|
+
return headers
|
|
94
|
+
|
|
95
|
+
def update_client_authorization(self, authorization: ClientAuthorization | None) -> None:
|
|
96
|
+
"""Replace the client's :class:`ClientAuthorization` strategy.
|
|
97
|
+
|
|
98
|
+
Validates that the new authorization is semantically compatible
|
|
99
|
+
with the existing one (e.g. the ``client_id`` must not change) so
|
|
100
|
+
that the client does not silently switch identity mid-session.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
authorization: The new :class:`ClientAuthorization` to use, or
|
|
104
|
+
``None`` to clear it.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ValueError: If the new authorization has a different
|
|
108
|
+
``client_id`` than the current one (when both are set).
|
|
109
|
+
TypeError: If the new authorization is a different type than
|
|
110
|
+
the current one (when both are set).
|
|
111
|
+
"""
|
|
112
|
+
existing = self.configuration.client_authorization
|
|
113
|
+
existing_id = existing.client_id if existing is not None else None
|
|
114
|
+
new_id = authorization.client_id if authorization is not None else None
|
|
115
|
+
if existing is not None and authorization is not None and type(existing) is not type(authorization):
|
|
116
|
+
raise TypeError(
|
|
117
|
+
f"Cannot change client authorization type from "
|
|
118
|
+
f"{type(existing).__name__!r} to "
|
|
119
|
+
f"{type(authorization).__name__!r}. "
|
|
120
|
+
f"Create a new OAuth2Client instead."
|
|
121
|
+
)
|
|
122
|
+
if existing_id is not None and new_id is not None and existing_id != new_id:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Cannot change client_id from {existing_id!r} to "
|
|
125
|
+
f"{new_id!r}. Create a new OAuth2Client instead."
|
|
126
|
+
)
|
|
127
|
+
object.__setattr__(self.configuration, "client_authorization", authorization)
|
|
128
|
+
|
|
129
|
+
async def fetch_openid_configuration(self, *, reset: bool = False) -> OpenIdConfiguration:
|
|
130
|
+
"""Fetch and cache OpenID discovery configuration.
|
|
131
|
+
|
|
132
|
+
Cache policy:
|
|
133
|
+
- ttl > 0: cache for ttl seconds (default 3600)
|
|
134
|
+
- ttl == 0: no caching (always fetch)
|
|
135
|
+
- ttl is None: cache indefinitely
|
|
136
|
+
"""
|
|
137
|
+
async def operation() -> OpenIdConfiguration:
|
|
138
|
+
request = OpenIDConfigurationRequest(
|
|
139
|
+
issuer=self.configuration.issuer,
|
|
140
|
+
client_id=self.configuration.client_id,
|
|
141
|
+
)
|
|
142
|
+
response = await asyncio.to_thread(self.send, request)
|
|
143
|
+
discovery = OpenIdConfiguration.from_json(_ensure_mapping(response.result))
|
|
144
|
+
configured = self.configuration.issuer.rstrip("/")
|
|
145
|
+
|
|
146
|
+
discovery_issuer = discovery.issuer
|
|
147
|
+
if discovery_issuer is None:
|
|
148
|
+
raise ValueError("Discovery document is missing required 'issuer' value")
|
|
149
|
+
|
|
150
|
+
discovery_issuer_normalized = discovery_issuer.rstrip("/")
|
|
151
|
+
if not discovery_issuer_normalized:
|
|
152
|
+
raise ValueError("Discovery document contains empty 'issuer' value")
|
|
153
|
+
|
|
154
|
+
if discovery_issuer_normalized != configured:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"Discovery issuer mismatch: expected {configured!r}, got {discovery_issuer!r}"
|
|
157
|
+
)
|
|
158
|
+
return discovery
|
|
159
|
+
|
|
160
|
+
return await self._openid_configuration_fetch.perform(operation=operation, reset=reset)
|
|
161
|
+
|
|
162
|
+
async def fetch_oauth_server_metadata(self, *, reset: bool = False) -> OAuthAuthorizationServer:
|
|
163
|
+
"""Fetch and cache OAuth authorization server metadata (cached by discovery configuration)."""
|
|
164
|
+
async def operation() -> OAuthAuthorizationServer:
|
|
165
|
+
request = OAuthAuthorizationServerRequest(issuer=self.configuration.issuer,
|
|
166
|
+
client_id=self.configuration.client_id)
|
|
167
|
+
response = await asyncio.to_thread(self.send, request)
|
|
168
|
+
metadata = OAuthAuthorizationServer.from_json(_ensure_mapping(response.result))
|
|
169
|
+
configured = self.configuration.issuer.rstrip("/")
|
|
170
|
+
if metadata.issuer.rstrip("/") != configured:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"Discovery issuer mismatch: expected {configured!r}, got {metadata.issuer!r}"
|
|
173
|
+
)
|
|
174
|
+
return metadata
|
|
175
|
+
|
|
176
|
+
return await self._oauth_server_metadata_fetch.perform(operation=operation, reset=reset)
|
|
177
|
+
|
|
178
|
+
def current_discovery_configuration(self) -> OpenIdConfiguration | OAuthAuthorizationServer | None:
|
|
179
|
+
"""Get the currently loaded discovery configuration, either OpenID or OAuth server metadata."""
|
|
180
|
+
discovery_configuration = self._openid_configuration_fetch.value
|
|
181
|
+
if discovery_configuration is None:
|
|
182
|
+
discovery_configuration = self._oauth_server_metadata_fetch.value
|
|
183
|
+
return discovery_configuration
|
|
184
|
+
|
|
185
|
+
async def fetch_jwks(self, *, reset: bool = False) -> JWKS:
|
|
186
|
+
"""Fetch the JWKS for the issuer (cached by discovery configuration)."""
|
|
187
|
+
async def operation() -> JWKS:
|
|
188
|
+
discovery_configuration = self.current_discovery_configuration()
|
|
189
|
+
|
|
190
|
+
# If the current discovery configuration doesn't have a jwks_uri, attempt to
|
|
191
|
+
# fetch the OpenID configuration directly as a fallback to get the jwks_uri.
|
|
192
|
+
#
|
|
193
|
+
# This allows clients that only fetch the OAuth authorization server metadata
|
|
194
|
+
# to still obtain the JWKS.
|
|
195
|
+
if discovery_configuration is None or not getattr(discovery_configuration, "jwks_uri", None):
|
|
196
|
+
discovery_configuration = await self.fetch_openid_configuration()
|
|
197
|
+
request = JWKSRequest(discovery_configuration=discovery_configuration,
|
|
198
|
+
client_id=self.configuration.client_id)
|
|
199
|
+
response = await asyncio.to_thread(self.send, request)
|
|
200
|
+
return JWKS.from_json(_ensure_mapping(response.result))
|
|
201
|
+
|
|
202
|
+
return await self._jwks_fetch.perform(operation=operation, reset=reset)
|
|
203
|
+
|
|
204
|
+
async def revoke(self, token: str, token_type_hint: str | None = None) -> None:
|
|
205
|
+
"""Revoke an access or refresh token."""
|
|
206
|
+
openid_config = await self.fetch_openid_configuration()
|
|
207
|
+
if not openid_config.revocation_endpoint:
|
|
208
|
+
raise ValueError("revocation_endpoint is not available")
|
|
209
|
+
request = RevokeRequest(
|
|
210
|
+
url=openid_config.revocation_endpoint,
|
|
211
|
+
token=token,
|
|
212
|
+
token_type_hint=token_type_hint,
|
|
213
|
+
client_id=self.configuration.client_id,
|
|
214
|
+
)
|
|
215
|
+
await asyncio.to_thread(self.send, request)
|
|
216
|
+
|
|
217
|
+
async def introspect(self, token: str) -> TokenInfo:
|
|
218
|
+
"""Introspect a token and return token metadata."""
|
|
219
|
+
openid_config = await self.fetch_openid_configuration()
|
|
220
|
+
if not openid_config.introspection_endpoint:
|
|
221
|
+
raise ValueError("introspection_endpoint is not available")
|
|
222
|
+
request = IntrospectRequest(
|
|
223
|
+
url=openid_config.introspection_endpoint,
|
|
224
|
+
token=token,
|
|
225
|
+
client_id=self.configuration.client_id,
|
|
226
|
+
)
|
|
227
|
+
response = await asyncio.to_thread(self.send, request)
|
|
228
|
+
return TokenInfo(claims=_ensure_mapping(response.result))
|
|
229
|
+
|
|
230
|
+
async def userinfo(self, token: Token) -> UserInfo:
|
|
231
|
+
"""Fetch the OIDC userinfo response using a Token."""
|
|
232
|
+
openid_config = await self.fetch_openid_configuration()
|
|
233
|
+
if not openid_config.userinfo_endpoint:
|
|
234
|
+
raise ValueError("userinfo_endpoint is not available")
|
|
235
|
+
request = UserInfoRequest(
|
|
236
|
+
url=openid_config.userinfo_endpoint,
|
|
237
|
+
authorization=token,
|
|
238
|
+
)
|
|
239
|
+
response = await asyncio.to_thread(self.send, request)
|
|
240
|
+
return UserInfo(claims=_ensure_mapping(response.result))
|
|
241
|
+
|
|
242
|
+
async def exchange(self, request: OAuth2TokenRequest) -> APIResponse[Token]:
|
|
243
|
+
"""Exchange a token request for a Token response with validation hooks."""
|
|
244
|
+
from ..token import Token, TokenContext
|
|
245
|
+
|
|
246
|
+
send_task = asyncio.create_task(asyncio.to_thread(self.send, request))
|
|
247
|
+
jwks_task = asyncio.create_task(self.fetch_jwks())
|
|
248
|
+
response = await send_task
|
|
249
|
+
result = _ensure_mapping(response.result)
|
|
250
|
+
_raise_for_oauth2_error(request, result, response)
|
|
251
|
+
|
|
252
|
+
issued_at = get_time_coordinator().now()
|
|
253
|
+
token_context = TokenContext(
|
|
254
|
+
issuer=request.discovery_configuration.issuer or request.client_configuration.issuer,
|
|
255
|
+
client_id=request.client_configuration.client_id,
|
|
256
|
+
client_settings=(request.client_configuration.additional_parameters or None),
|
|
257
|
+
)
|
|
258
|
+
jwt_context = self._build_jwt_context(request)
|
|
259
|
+
jwks = await jwks_task
|
|
260
|
+
token = Token.from_response(
|
|
261
|
+
result,
|
|
262
|
+
context=token_context,
|
|
263
|
+
issued_at=issued_at,
|
|
264
|
+
jwks=jwks,
|
|
265
|
+
jwt_context=jwt_context,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return APIResponse(
|
|
269
|
+
result=token,
|
|
270
|
+
status_code=response.status_code,
|
|
271
|
+
headers=response.headers,
|
|
272
|
+
request_id=response.request_id,
|
|
273
|
+
rate_limit=response.rate_limit,
|
|
274
|
+
links=response.links,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def refresh(self, token: Token, scope: Sequence[str] | None = None) -> Token:
|
|
278
|
+
"""Refresh the supplied token using its refresh token."""
|
|
279
|
+
from ..token import Token
|
|
280
|
+
|
|
281
|
+
refresh_token = token.refresh_token
|
|
282
|
+
if not refresh_token:
|
|
283
|
+
raise OAuth2Error(
|
|
284
|
+
error="missing_refresh_token",
|
|
285
|
+
error_description="Token does not contain a refresh_token",
|
|
286
|
+
)
|
|
287
|
+
normalized_scope = coerce_optional_sequence(scope)
|
|
288
|
+
refresh_key = (refresh_token, tuple(normalized_scope) if normalized_scope else None)
|
|
289
|
+
async with self._refresh_lock:
|
|
290
|
+
refresh_action = self._refresh_actions.get(refresh_key)
|
|
291
|
+
if refresh_action is None:
|
|
292
|
+
refresh_action = CoalescedResult[Token](ttl=0, time_provider=self._time_provider)
|
|
293
|
+
self._refresh_actions[refresh_key] = refresh_action
|
|
294
|
+
|
|
295
|
+
flow = RefreshTokenFlow(client=self)
|
|
296
|
+
|
|
297
|
+
async def operation() -> Token:
|
|
298
|
+
self._notify_will_refresh(token)
|
|
299
|
+
try:
|
|
300
|
+
refreshed = await flow.start(refresh_token, scope=scope)
|
|
301
|
+
except Exception:
|
|
302
|
+
self._notify_did_refresh(token, None)
|
|
303
|
+
raise
|
|
304
|
+
merged = refreshed.merge(token)
|
|
305
|
+
self._notify_did_refresh(token, merged)
|
|
306
|
+
return merged
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
return await refresh_action.perform(operation=operation, reset=True)
|
|
310
|
+
finally:
|
|
311
|
+
async with self._refresh_lock:
|
|
312
|
+
self._refresh_actions.pop(refresh_key, None)
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
async def from_refresh_token(
|
|
316
|
+
refresh_token: str,
|
|
317
|
+
*,
|
|
318
|
+
scope: Sequence[str] | None = None,
|
|
319
|
+
client: OAuth2Client,
|
|
320
|
+
) -> Token:
|
|
321
|
+
"""Create a new Token from a refresh token using the provided client."""
|
|
322
|
+
from okta_client.authfoundation.authentication import StandardAuthenticationContext
|
|
323
|
+
|
|
324
|
+
openid_configuration = await client.fetch_openid_configuration()
|
|
325
|
+
request = RefreshTokenRequest(
|
|
326
|
+
_openid_configuration=openid_configuration,
|
|
327
|
+
_client_configuration=client.configuration,
|
|
328
|
+
additional_parameters=None,
|
|
329
|
+
context=StandardAuthenticationContext(),
|
|
330
|
+
refresh_token=refresh_token,
|
|
331
|
+
scope=scope,
|
|
332
|
+
)
|
|
333
|
+
response = await client.exchange(request)
|
|
334
|
+
return response.result
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _build_jwt_context(
|
|
338
|
+
request: OAuth2TokenRequest,
|
|
339
|
+
) -> JWTValidationContext:
|
|
340
|
+
validator_context = getattr(request, "token_validator_context", None)
|
|
341
|
+
issuer = request.client_configuration.issuer
|
|
342
|
+
audience = request.client_configuration.client_id
|
|
343
|
+
return JWTValidationContext(
|
|
344
|
+
issuer=issuer,
|
|
345
|
+
audience=audience,
|
|
346
|
+
nonce=validator_context.nonce if validator_context else None,
|
|
347
|
+
max_age=validator_context.max_age if validator_context else None,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def _notify_will_refresh(self, token: Token) -> None:
|
|
351
|
+
for listener in self._listeners:
|
|
352
|
+
if isinstance(listener, OAuth2ClientListener):
|
|
353
|
+
listener.will_refresh_token(self, token)
|
|
354
|
+
|
|
355
|
+
def _notify_did_refresh(self, token: Token, refreshed: Token | None) -> None:
|
|
356
|
+
for listener in self._listeners:
|
|
357
|
+
if isinstance(listener, OAuth2ClientListener):
|
|
358
|
+
listener.did_refresh_token(self, token, refreshed)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _ensure_mapping(result: Any) -> Mapping[str, Any]:
|
|
362
|
+
if isinstance(result, Mapping):
|
|
363
|
+
return result
|
|
364
|
+
raise ValueError("Token response is not a JSON object")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _raise_for_oauth2_error(
|
|
368
|
+
request: OAuth2TokenRequest,
|
|
369
|
+
result: Mapping[str, Any],
|
|
370
|
+
response: APIResponse[Any],
|
|
371
|
+
) -> None:
|
|
372
|
+
error = None
|
|
373
|
+
if hasattr(request, "parse_error"):
|
|
374
|
+
try:
|
|
375
|
+
error = request.parse_error(result)
|
|
376
|
+
except Exception:
|
|
377
|
+
error = None
|
|
378
|
+
if error is None and ("error" in result or response.status_code >= 400):
|
|
379
|
+
error = OAuth2Error(
|
|
380
|
+
error=str(result.get("error", "oauth2_error")),
|
|
381
|
+
error_description=coerce_optional_str(result.get("error_description")),
|
|
382
|
+
error_uri=coerce_optional_str(result.get("error_uri")),
|
|
383
|
+
status_code=response.status_code,
|
|
384
|
+
request_id=response.request_id,
|
|
385
|
+
)
|
|
386
|
+
if error is None:
|
|
387
|
+
return
|
|
388
|
+
raise error
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _build_jwt_context(
|
|
392
|
+
request: OAuth2TokenRequest,
|
|
393
|
+
) -> JWTValidationContext:
|
|
394
|
+
validator_context = getattr(request, "token_validator_context", None)
|
|
395
|
+
issuer = request.discovery_configuration.issuer or request.client_configuration.issuer
|
|
396
|
+
audience = request.client_configuration.client_id
|
|
397
|
+
return JWTValidationContext(
|
|
398
|
+
issuer=issuer,
|
|
399
|
+
audience=audience,
|
|
400
|
+
nonce=validator_context.nonce if validator_context else None,
|
|
401
|
+
max_age=validator_context.max_age if validator_context else None,
|
|
402
|
+
)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# The Okta software accompanied by this notice is provided pursuant to the following terms:
|
|
2
|
+
# Copyright © 2026-Present, Okta, Inc.
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the
|
|
4
|
+
# License.
|
|
5
|
+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
|
|
6
|
+
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
|
|
7
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
8
|
+
# See the License for the specific language governing permissions and limitations under the License.
|
|
9
|
+
# coding: utf-8
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
import jwt
|
|
18
|
+
|
|
19
|
+
from ..key_provider import DefaultKeyProvider, KeyProvider, get_key_provider
|
|
20
|
+
from ..networking import RequestValue
|
|
21
|
+
from .jwt_bearer_claims import JWTBearerClaims
|
|
22
|
+
from .jwt_bearer_utils import resolve_jwt_bearer_assertion
|
|
23
|
+
from .parameters import OAuth2APIRequestCategory, ProvidesOAuth2Parameters
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClientAssertionType(str, Enum):
|
|
27
|
+
"""Supported client assertion types."""
|
|
28
|
+
|
|
29
|
+
JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_CLIENT_ID_MISSING = object()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ClientAuthorization(ProvidesOAuth2Parameters):
|
|
37
|
+
"""Base class for OAuth2 client authorization strategies."""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def client_id(self) -> str | None:
|
|
41
|
+
"""Return the client ID if available."""
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def parameters(self, category: OAuth2APIRequestCategory) -> Mapping[str, RequestValue] | None:
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ClientIdAuthorization(ClientAuthorization):
|
|
49
|
+
"""Client authentication using a client ID parameter."""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def client_id(self) -> str | None:
|
|
55
|
+
return self.id
|
|
56
|
+
|
|
57
|
+
def parameters(self, category: OAuth2APIRequestCategory) -> Mapping[str, RequestValue] | None:
|
|
58
|
+
if category == OAuth2APIRequestCategory.CONFIGURATION:
|
|
59
|
+
return None
|
|
60
|
+
return {"client_id": self.id}
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ClientSecretAuthorization(ClientIdAuthorization):
|
|
64
|
+
"""Client authentication using a client ID and secret parameter."""
|
|
65
|
+
|
|
66
|
+
secret: str
|
|
67
|
+
|
|
68
|
+
def parameters(self, category: OAuth2APIRequestCategory) -> Mapping[str, RequestValue] | None:
|
|
69
|
+
if category == OAuth2APIRequestCategory.CONFIGURATION:
|
|
70
|
+
return None
|
|
71
|
+
return {
|
|
72
|
+
"client_id": self.id,
|
|
73
|
+
"client_secret": self.secret,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class ClientAssertionAuthorization(ClientAuthorization):
|
|
78
|
+
"""Client authentication using a JWT assertion (RFC 7523)."""
|
|
79
|
+
|
|
80
|
+
assertion: str | None = None
|
|
81
|
+
assertion_claims: JWTBearerClaims | None = None
|
|
82
|
+
key_provider: KeyProvider | None = None
|
|
83
|
+
assertion_type: ClientAssertionType = ClientAssertionType.JWT_BEARER
|
|
84
|
+
_cached_client_id: object = field(default=_CLIENT_ID_MISSING, init=False, repr=False, compare=False)
|
|
85
|
+
|
|
86
|
+
def __post_init__(self) -> None:
|
|
87
|
+
"""Validate inputs and resolve the key provider eagerly.
|
|
88
|
+
|
|
89
|
+
Ensures the instance is usable at construction time:
|
|
90
|
+
|
|
91
|
+
* Exactly one of ``assertion`` or ``assertion_claims`` must be
|
|
92
|
+
provided.
|
|
93
|
+
* When ``assertion_claims`` is set without an explicit
|
|
94
|
+
``key_provider``, the global key provider is captured. If no
|
|
95
|
+
usable global provider is configured, a :class:`ValueError` is
|
|
96
|
+
raised immediately rather than deferring the error to the first
|
|
97
|
+
token request.
|
|
98
|
+
"""
|
|
99
|
+
if self.assertion is not None and self.assertion_claims is not None:
|
|
100
|
+
raise ValueError("Provide either 'assertion' or 'assertion_claims', not both")
|
|
101
|
+
|
|
102
|
+
if self.assertion is None and self.assertion_claims is None:
|
|
103
|
+
raise ValueError("Either 'assertion' or 'assertion_claims' is required")
|
|
104
|
+
|
|
105
|
+
if self.assertion_claims is not None and self.key_provider is None:
|
|
106
|
+
global_provider = get_key_provider()
|
|
107
|
+
if isinstance(global_provider, DefaultKeyProvider):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"'assertion_claims' requires a key provider to sign the "
|
|
110
|
+
"JWT. Supply an explicit 'key_provider' or configure one "
|
|
111
|
+
"globally via set_key_provider()."
|
|
112
|
+
)
|
|
113
|
+
object.__setattr__(self, "key_provider", global_provider)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def client_id(self) -> str | None:
|
|
117
|
+
if self._cached_client_id is _CLIENT_ID_MISSING:
|
|
118
|
+
resolved = self._resolve_client_id()
|
|
119
|
+
object.__setattr__(self, "_cached_client_id", resolved)
|
|
120
|
+
cached = self._cached_client_id
|
|
121
|
+
return cached if isinstance(cached, str) else None
|
|
122
|
+
|
|
123
|
+
def parameters(self, category: OAuth2APIRequestCategory) -> Mapping[str, RequestValue] | None:
|
|
124
|
+
if category == OAuth2APIRequestCategory.CONFIGURATION:
|
|
125
|
+
return None
|
|
126
|
+
assertion = self._resolve_assertion()
|
|
127
|
+
assertion_type = (
|
|
128
|
+
self.assertion_type.value
|
|
129
|
+
if isinstance(self.assertion_type, ClientAssertionType)
|
|
130
|
+
else str(self.assertion_type)
|
|
131
|
+
)
|
|
132
|
+
return {
|
|
133
|
+
"client_assertion_type": assertion_type,
|
|
134
|
+
"client_assertion": assertion,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
def _resolve_assertion(self) -> str:
|
|
138
|
+
return resolve_jwt_bearer_assertion(
|
|
139
|
+
assertion=self.assertion,
|
|
140
|
+
assertion_claims=self.assertion_claims,
|
|
141
|
+
key_provider=self.key_provider,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _resolve_client_id(self) -> str | None:
|
|
145
|
+
if self.assertion_claims and self.assertion_claims.issuer:
|
|
146
|
+
return self.assertion_claims.issuer
|
|
147
|
+
if self.assertion_claims is None and self.assertion_type == ClientAssertionType.JWT_BEARER:
|
|
148
|
+
return self._extract_issuer_from_assertion()
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def _extract_issuer_from_assertion(self) -> str | None:
|
|
152
|
+
assertion = self.assertion
|
|
153
|
+
if not assertion:
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
claims = jwt.decode(
|
|
157
|
+
assertion,
|
|
158
|
+
options={
|
|
159
|
+
"enforce_minimum_key_length": False,
|
|
160
|
+
"verify_signature": False,
|
|
161
|
+
"verify_aud": False,
|
|
162
|
+
"verify_exp": False,
|
|
163
|
+
"verify_iss": False,
|
|
164
|
+
"verify_nbf": False,
|
|
165
|
+
"verify_iat": False,
|
|
166
|
+
"verify_jti": False,
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
except Exception:
|
|
170
|
+
return None
|
|
171
|
+
iss = claims.get("iss") if isinstance(claims, dict) else None
|
|
172
|
+
return str(iss) if iss else None
|