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,130 @@
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 json
14
+ import threading
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+ from typing import Any, Protocol, runtime_checkable
18
+
19
+ import jwt
20
+ from jwt import algorithms as jwt_algorithms
21
+
22
+ _ALLOWED_ALGORITHMS = {
23
+ "HS256",
24
+ "HS384",
25
+ "HS512",
26
+ "RS256",
27
+ "RS384",
28
+ "RS512",
29
+ "ES256",
30
+ "ES384",
31
+ "ES512",
32
+ "PS256",
33
+ "PS384",
34
+ "PS512",
35
+ }
36
+
37
+
38
+ @runtime_checkable
39
+ class KeyProvider(Protocol):
40
+ """Protocol for signing JWT assertions."""
41
+
42
+ algorithm: str
43
+ key_id: str | None
44
+
45
+ def sign_jwt(self, claims: Mapping[str, object], headers: Mapping[str, object] | None = None) -> str:
46
+ """Sign the given claims and return a compact JWT string."""
47
+ ...
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class LocalKeyProvider(KeyProvider):
52
+ """Key provider that signs JWTs using local key material."""
53
+
54
+ key: str | bytes | Mapping[str, Any]
55
+ algorithm: str = "RS256"
56
+ key_id: str | None = None
57
+
58
+ def __post_init__(self) -> None:
59
+ if self.algorithm not in _ALLOWED_ALGORITHMS:
60
+ raise ValueError(f"Unsupported JWT algorithm: {self.algorithm}")
61
+
62
+ def sign_jwt(self, claims: Mapping[str, object], headers: Mapping[str, object] | None = None) -> str:
63
+ if not claims:
64
+ raise ValueError("claims must not be empty")
65
+ encoded_headers = dict(headers or {})
66
+ if self.key_id and "kid" not in encoded_headers:
67
+ encoded_headers["kid"] = self.key_id
68
+ key = _resolve_key_material(self.key, self.algorithm)
69
+ token = jwt.encode(payload=dict(claims), key=key, algorithm=self.algorithm, headers=encoded_headers or None)
70
+ return token.decode("utf-8") if isinstance(token, bytes) else token
71
+
72
+ @classmethod
73
+ def from_pem(
74
+ cls,
75
+ pem: str,
76
+ *,
77
+ algorithm: str = "RS256",
78
+ key_id: str | None = None,
79
+ ) -> LocalKeyProvider:
80
+ return cls(pem, algorithm=algorithm, key_id=key_id)
81
+
82
+ @classmethod
83
+ def from_pem_file(
84
+ cls,
85
+ path: str,
86
+ *,
87
+ algorithm: str = "RS256",
88
+ key_id: str | None = None,
89
+ encoding: str = "utf-8",
90
+ ) -> LocalKeyProvider:
91
+ with open(path, encoding=encoding) as handle:
92
+ return cls(handle.read(), algorithm=algorithm, key_id=key_id)
93
+
94
+
95
+ class DefaultKeyProvider(KeyProvider):
96
+ """Default provider that requires explicit configuration."""
97
+
98
+ algorithm: str = ""
99
+ key_id: str | None = None
100
+
101
+ def sign_jwt(self, claims: Mapping[str, object], headers: Mapping[str, object] | None = None) -> str:
102
+ raise RuntimeError("KeyProvider is not configured. Use set_key_provider() to supply a signer.")
103
+
104
+
105
+ _default_key_provider: KeyProvider = DefaultKeyProvider()
106
+ _key_provider_lock = threading.Lock()
107
+
108
+
109
+ def get_key_provider() -> KeyProvider:
110
+ """Get the global KeyProvider in a thread-safe manner."""
111
+ with _key_provider_lock:
112
+ return _default_key_provider
113
+
114
+
115
+ def set_key_provider(provider: KeyProvider) -> None:
116
+ """Set the global KeyProvider in a thread-safe manner."""
117
+ global _default_key_provider
118
+ with _key_provider_lock:
119
+ _default_key_provider = provider
120
+
121
+
122
+ def _resolve_key_material(key: str | bytes | Mapping[str, Any], algorithm: str) -> Any:
123
+ if isinstance(key, (str, bytes)):
124
+ return key
125
+ if isinstance(key, Mapping):
126
+ algorithms = jwt_algorithms.get_default_algorithms()
127
+ if algorithm not in algorithms:
128
+ raise ValueError(f"Unsupported JWT algorithm: {algorithm}")
129
+ return algorithms[algorithm].from_jwk(json.dumps(dict(key)))
130
+ raise TypeError("key must be a PEM string, bytes, or JWK mapping")
@@ -0,0 +1,56 @@
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 .body import APIRequestBodyMixin
12
+ from .client import APIClient, DefaultNetworkInterface
13
+ from .types import (
14
+ APIAuthorization,
15
+ APIClientConfiguration,
16
+ APIClientListener,
17
+ APIContentType,
18
+ APIParsingContext,
19
+ APIRateLimit,
20
+ APIRequest,
21
+ APIRequestBody,
22
+ APIRequestMethod,
23
+ APIResponse,
24
+ APIRetry,
25
+ BaseAPIRequest,
26
+ HTTPRequest,
27
+ ListenerCollection,
28
+ NetworkInterface,
29
+ RawResponse,
30
+ RequestValue,
31
+ RequestValueConvertible,
32
+ )
33
+
34
+ __all__ = [
35
+ "APIAuthorization",
36
+ "APIClient",
37
+ "APIClientConfiguration",
38
+ "APIClientListener",
39
+ "APIContentType",
40
+ "APIParsingContext",
41
+ "APIRateLimit",
42
+ "APIRequest",
43
+ "APIRequestBody",
44
+ "APIRequestBodyMixin",
45
+ "APIRequestMethod",
46
+ "APIResponse",
47
+ "APIRetry",
48
+ "BaseAPIRequest",
49
+ "DefaultNetworkInterface",
50
+ "HTTPRequest",
51
+ "ListenerCollection",
52
+ "NetworkInterface",
53
+ "RawResponse",
54
+ "RequestValue",
55
+ "RequestValueConvertible",
56
+ ]
@@ -0,0 +1,46 @@
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 json
14
+ from typing import Any
15
+ from urllib.parse import parse_qs, urlencode
16
+
17
+ from okta_client.authfoundation.utils import serialize_parameters
18
+
19
+ from .types import APIContentType, APIParsingContext, APIRequestBody, RawResponse
20
+
21
+
22
+ class APIRequestBodyMixin(APIRequestBody):
23
+ """Mixin that serializes body parameters based on content type."""
24
+
25
+ @property
26
+ def content_type(self) -> APIContentType | None:
27
+ raise NotImplementedError
28
+
29
+ @property
30
+ def accepts_type(self) -> APIContentType | None:
31
+ raise NotImplementedError
32
+
33
+ def body(self) -> bytes | None:
34
+ params = serialize_parameters(self.body_parameters)
35
+ if self.content_type == APIContentType.JSON:
36
+ return json.dumps(params).encode("utf-8")
37
+ return urlencode(params).encode("utf-8")
38
+
39
+ def parse_response(self, response: RawResponse, parsing_context: APIParsingContext | None = None) -> Any:
40
+ if not response.body:
41
+ return {}
42
+ if self.accepts_type == APIContentType.JSON:
43
+ return json.loads(response.body.decode("utf-8"))
44
+ if self.accepts_type == APIContentType.FORM_URLENCODED:
45
+ return parse_qs(response.body.decode("utf-8"))
46
+ return response.body.decode("utf-8")
@@ -0,0 +1,200 @@
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 json
14
+ import urllib.error
15
+ import urllib.request
16
+ from collections.abc import Mapping
17
+ from typing import Any, TypeVar
18
+ from urllib.parse import urlencode, urlsplit, urlunsplit
19
+
20
+ from okta_client.authfoundation.utils import serialize_parameters, serialize_request_value
21
+
22
+ from .types import (
23
+ APIClientConfiguration,
24
+ APIClientListener,
25
+ APIContentType,
26
+ APIParsingContext,
27
+ APIRateLimit,
28
+ APIRequest,
29
+ APIRequestBody,
30
+ APIResponse,
31
+ APIRetry,
32
+ HTTPRequest,
33
+ ListenerCollection,
34
+ NetworkInterface,
35
+ RawResponse,
36
+ RequestValue,
37
+ )
38
+
39
+ T = TypeVar("T")
40
+
41
+
42
+ class APIClient:
43
+ """Network client that executes APIRequest objects and parses responses."""
44
+ def __init__(
45
+ self,
46
+ base_url: str | None = None,
47
+ user_agent: str | None = None,
48
+ additional_http_headers: Mapping[str, str] | None = None,
49
+ request_id_header: str | None = None,
50
+ configuration: APIClientConfiguration | None = None,
51
+ network: NetworkInterface | None = None,
52
+ ) -> None:
53
+ """Create a client with configuration and network transport."""
54
+ if configuration is None:
55
+ if base_url is None or user_agent is None:
56
+ raise ValueError("base_url and user_agent are required when configuration is not provided")
57
+ configuration = APIClientConfiguration(
58
+ base_url=base_url,
59
+ user_agent=user_agent,
60
+ additional_http_headers=additional_http_headers or {},
61
+ request_id_header=request_id_header,
62
+ )
63
+ self.configuration = configuration
64
+ self.base_url = configuration.base_url
65
+ self.user_agent = configuration.user_agent
66
+ self.network = network or DefaultNetworkInterface()
67
+ self.additional_http_headers = dict(configuration.additional_http_headers or {})
68
+ self.request_id_header = configuration.request_id_header
69
+ self._listeners: ListenerCollection[APIClientListener] = ListenerCollection()
70
+
71
+ @property
72
+ def listeners(self) -> ListenerCollection[APIClientListener]:
73
+ """Registered listeners for request lifecycle events."""
74
+ return self._listeners
75
+
76
+ def send(self, request: APIRequest[T], parsing_context: APIParsingContext | None = None) -> APIResponse[T]:
77
+ """Send an APIRequest and return a typed APIResponse."""
78
+ http_request = self.build_http_request(request)
79
+ self.will_send(http_request)
80
+ try:
81
+ raw_response = self._send_once(request, http_request=http_request)
82
+ result = request.parse_response(raw_response, parsing_context=parsing_context)
83
+ response = APIResponse(
84
+ result=result,
85
+ status_code=raw_response.status_code,
86
+ headers=raw_response.headers,
87
+ request_id=self._extract_request_id(raw_response.headers),
88
+ rate_limit=None,
89
+ links=None,
90
+ )
91
+ self.did_send(http_request, response)
92
+ return response
93
+ except Exception as error:
94
+ self.did_send_error(http_request, error)
95
+ raise
96
+
97
+ def will_send(self, request: HTTPRequest) -> None:
98
+ """Notify listeners before a request is sent."""
99
+ for listener in self._listeners:
100
+ listener.will_send(self, request)
101
+
102
+ def did_send(self, request: HTTPRequest, response: APIResponse[Any]) -> None:
103
+ """Notify listeners after a successful response."""
104
+ for listener in self._listeners:
105
+ listener.did_send(self, request, response)
106
+
107
+ def did_send_error(self, request: HTTPRequest, error: Exception) -> None:
108
+ """Notify listeners after a failed request."""
109
+ for listener in self._listeners:
110
+ listener.did_send_error(self, request, error)
111
+
112
+ def should_retry(self, request: HTTPRequest, rate_limit: APIRateLimit | None = None) -> APIRetry:
113
+ """Ask listeners for a retry policy for a request."""
114
+ for listener in self._listeners:
115
+ retry = listener.should_retry(self, request, rate_limit)
116
+ if retry.kind != "default":
117
+ return retry
118
+ return APIRetry.default()
119
+
120
+ def _send_once(self, request: APIRequest[T], http_request: HTTPRequest | None = None) -> RawResponse:
121
+ """Send a request once using the configured network interface."""
122
+ if not self.network:
123
+ raise RuntimeError("APIClient.network is not configured")
124
+ built_request = http_request or self.build_http_request(request)
125
+ return self.network.send(built_request)
126
+
127
+ def build_http_request(self, request: APIRequest[Any]) -> HTTPRequest:
128
+ """Build a platform HTTP request from an APIRequest."""
129
+ headers = dict(self._build_headers(request))
130
+ url = self._build_url(request.url, request.query)
131
+ body = self._build_body(request)
132
+ timeout = request.timeout if request.timeout is not None else self.configuration.timeout
133
+ http_request = HTTPRequest(method=request.http_method, url=url, headers=headers, body=body, timeout=timeout)
134
+ if request.authorization:
135
+ http_request = request.authorization.authorize(http_request)
136
+ return http_request
137
+
138
+ def _build_headers(self, request: APIRequest[Any]) -> Mapping[str, str]:
139
+ """Build request headers from defaults and request data."""
140
+ headers: dict[str, str] = {}
141
+ headers.update(self.additional_http_headers)
142
+ headers["User-Agent"] = self.user_agent
143
+ if request.headers:
144
+ for key, value in request.headers.items():
145
+ serialized = serialize_request_value(value)
146
+ if serialized is not None:
147
+ headers[key] = serialized
148
+ if request.accepts_type:
149
+ headers["Accept"] = request.accepts_type.value
150
+ if request.content_type:
151
+ headers["Content-Type"] = request.content_type.value
152
+ return headers
153
+
154
+ def _build_url(self, url: str, query: Mapping[str, RequestValue] | None) -> str:
155
+ """Build a URL with merged query parameters."""
156
+ if not query:
157
+ return url
158
+ query_string = urlencode(serialize_parameters(query))
159
+ split = urlsplit(url)
160
+ merged_query = "&".join(filter(None, [split.query, query_string]))
161
+ return urlunsplit((split.scheme, split.netloc, split.path, merged_query, split.fragment))
162
+
163
+ def _build_body(self, request: APIRequest[Any]) -> bytes | None:
164
+ """Serialize the request body based on content type."""
165
+ if isinstance(request, APIRequestBody):
166
+ params = serialize_parameters(request.body_parameters)
167
+ if request.content_type == APIContentType.JSON:
168
+ return json.dumps(params).encode("utf-8")
169
+ return urlencode(params).encode("utf-8")
170
+ return request.body()
171
+
172
+ def _extract_request_id(self, headers: Mapping[str, str]) -> str | None:
173
+ """Extract a request ID from response headers, if configured."""
174
+ if not self.request_id_header:
175
+ return None
176
+ for key, value in headers.items():
177
+ if key.lower() == self.request_id_header.lower():
178
+ return value
179
+ return None
180
+
181
+
182
+ class DefaultNetworkInterface(NetworkInterface):
183
+ """Default network interface using urllib.request."""
184
+
185
+ def send(self, request: HTTPRequest) -> RawResponse:
186
+ req = urllib.request.Request(
187
+ request.url,
188
+ data=request.body,
189
+ headers=request.headers,
190
+ method=request.method.value,
191
+ )
192
+ try:
193
+ with urllib.request.urlopen(req, timeout=request.timeout) as response:
194
+ body = response.read() or b""
195
+ headers = {k: v for k, v in response.headers.items()}
196
+ return RawResponse(status_code=response.status, headers=headers, body=body)
197
+ except urllib.error.HTTPError as error:
198
+ body = error.read() or b""
199
+ headers = {k: v for k, v in error.headers.items()}
200
+ return RawResponse(status_code=error.code, headers=headers, body=body)