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.
Files changed (56) hide show
  1. okta_client/__init__.py +20 -0
  2. okta_client/authfoundation/__init__.py +197 -0
  3. okta_client/authfoundation/authentication.py +247 -0
  4. okta_client/authfoundation/coalesced_result.py +113 -0
  5. okta_client/authfoundation/codable.py +72 -0
  6. okta_client/authfoundation/expires.py +49 -0
  7. okta_client/authfoundation/key_provider.py +130 -0
  8. okta_client/authfoundation/networking/__init__.py +56 -0
  9. okta_client/authfoundation/networking/body.py +46 -0
  10. okta_client/authfoundation/networking/client.py +200 -0
  11. okta_client/authfoundation/networking/types.py +293 -0
  12. okta_client/authfoundation/oauth2/__init__.py +104 -0
  13. okta_client/authfoundation/oauth2/claims.py +44 -0
  14. okta_client/authfoundation/oauth2/client.py +402 -0
  15. okta_client/authfoundation/oauth2/client_authorization.py +172 -0
  16. okta_client/authfoundation/oauth2/config.py +298 -0
  17. okta_client/authfoundation/oauth2/errors.py +32 -0
  18. okta_client/authfoundation/oauth2/jwt_bearer_claims.py +59 -0
  19. okta_client/authfoundation/oauth2/jwt_bearer_utils.py +30 -0
  20. okta_client/authfoundation/oauth2/jwt_context.py +52 -0
  21. okta_client/authfoundation/oauth2/jwt_token.py +214 -0
  22. okta_client/authfoundation/oauth2/models.py +198 -0
  23. okta_client/authfoundation/oauth2/parameters.py +36 -0
  24. okta_client/authfoundation/oauth2/refresh_token.py +165 -0
  25. okta_client/authfoundation/oauth2/request_protocols.py +174 -0
  26. okta_client/authfoundation/oauth2/requests/__init__.py +37 -0
  27. okta_client/authfoundation/oauth2/requests/introspect.py +50 -0
  28. okta_client/authfoundation/oauth2/requests/jwks.py +44 -0
  29. okta_client/authfoundation/oauth2/requests/oauth_authorization_server.py +44 -0
  30. okta_client/authfoundation/oauth2/requests/openid_configuration.py +47 -0
  31. okta_client/authfoundation/oauth2/requests/revoke.py +54 -0
  32. okta_client/authfoundation/oauth2/requests/user_info.py +37 -0
  33. okta_client/authfoundation/oauth2/utils.py +25 -0
  34. okta_client/authfoundation/oauth2/validation_protocols.py +33 -0
  35. okta_client/authfoundation/oauth2/validator_registry.py +64 -0
  36. okta_client/authfoundation/oauth2/validators/token_hash.py +37 -0
  37. okta_client/authfoundation/oauth2/validators/token_validator.py +26 -0
  38. okta_client/authfoundation/time_coordinator.py +57 -0
  39. okta_client/authfoundation/token.py +201 -0
  40. okta_client/authfoundation/user_agent.py +80 -0
  41. okta_client/authfoundation/utils.py +63 -0
  42. okta_client/browser_signin/__init__.py +11 -0
  43. okta_client/directauth/__init__.py +11 -0
  44. okta_client/oauth2auth/__init__.py +63 -0
  45. okta_client/oauth2auth/authorization_code.py +594 -0
  46. okta_client/oauth2auth/cross_app.py +626 -0
  47. okta_client/oauth2auth/jwt_bearer.py +182 -0
  48. okta_client/oauth2auth/resource_owner.py +159 -0
  49. okta_client/oauth2auth/token_exchange.py +380 -0
  50. okta_client/oauth2auth/utils.py +87 -0
  51. okta_client/py.typed +0 -0
  52. okta_client_python-0.1.0.dist-info/METADATA +936 -0
  53. okta_client_python-0.1.0.dist-info/RECORD +56 -0
  54. okta_client_python-0.1.0.dist-info/WHEEL +5 -0
  55. okta_client_python-0.1.0.dist-info/licenses/LICENSE +171 -0
  56. 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