oauth2-cli-auth 2.0.0__tar.gz → 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {oauth2_cli_auth-2.0.0 → oauth2_cli_auth-3.0.0}/PKG-INFO +5 -2
- {oauth2_cli_auth-2.0.0 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/__init__.py +1 -1
- oauth2_cli_auth-3.0.0/oauth2_cli_auth/__version__.py +1 -0
- {oauth2_cli_auth-2.0.0 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/code_grant.py +37 -8
- {oauth2_cli_auth-2.0.0 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/http_server.py +31 -7
- {oauth2_cli_auth-2.0.0 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/simplified_flow.py +6 -2
- {oauth2_cli_auth-2.0.0 → oauth2_cli_auth-3.0.0}/pyproject.toml +3 -3
- oauth2_cli_auth-2.0.0/oauth2_cli_auth/__version__.py +0 -1
- {oauth2_cli_auth-2.0.0 → oauth2_cli_auth-3.0.0}/LICENSE +0 -0
- {oauth2_cli_auth-2.0.0 → oauth2_cli_auth-3.0.0}/README.md +0 -0
- {oauth2_cli_auth-2.0.0 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/_timeout.py +0 -0
- {oauth2_cli_auth-2.0.0 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/_urllib_util.py +0 -0
- {oauth2_cli_auth-2.0.0 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/conftest.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: oauth2-cli-auth
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Authenticate against OAuth2 Provider in Python CLIs
|
|
5
5
|
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Author: Timo Reymann
|
|
7
8
|
Author-email: mail@timo-reymann.de
|
|
8
9
|
Requires-Python: >=3.10
|
|
@@ -19,6 +20,8 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
19
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
22
25
|
Project-URL: Bug Tracker, https://github.com/timo-reymann/python-oauth2-cli-auth/issues
|
|
23
26
|
Project-URL: Repository, https://github.com/timo-reymann/python-oauth2-cli-auth
|
|
24
27
|
Description-Content-Type: text/markdown
|
|
@@ -3,5 +3,5 @@ Authenticate against OAuth2 Provider in Python CLIs
|
|
|
3
3
|
"""
|
|
4
4
|
from oauth2_cli_auth.__version__ import __version__
|
|
5
5
|
from oauth2_cli_auth.http_server import OAuthCallbackHttpServer
|
|
6
|
-
from oauth2_cli_auth.code_grant import OAuth2ClientInfo, refresh_access_token, exchange_code_for_access_token, exchange_code_for_response, get_auth_url, open_browser, load_oidc_config
|
|
6
|
+
from oauth2_cli_auth.code_grant import OAuth2ClientInfo, refresh_access_token, exchange_code_for_access_token, exchange_code_for_response, get_auth_url, generate_pkce_pair, open_browser, load_oidc_config
|
|
7
7
|
from oauth2_cli_auth.simplified_flow import get_access_token_with_browser_open
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.0.0"
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Functionality to work with OAuth2 code grant
|
|
3
3
|
"""
|
|
4
4
|
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import secrets
|
|
5
7
|
import urllib.parse
|
|
6
8
|
import urllib.request
|
|
7
9
|
import webbrowser
|
|
@@ -76,25 +78,47 @@ def open_browser(url: str, print_open_browser_instruction: Optional[Callable[[st
|
|
|
76
78
|
webbrowser.open(url)
|
|
77
79
|
|
|
78
80
|
|
|
79
|
-
def
|
|
81
|
+
def generate_pkce_pair() -> tuple[str, str]:
|
|
82
|
+
"""
|
|
83
|
+
Generate a PKCE (RFC 7636) code verifier and its S256 code challenge
|
|
84
|
+
|
|
85
|
+
:return: Tuple of (code_verifier, code_challenge)
|
|
86
|
+
"""
|
|
87
|
+
verifier = secrets.token_urlsafe(64)
|
|
88
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
89
|
+
return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_auth_url(client_info: OAuth2ClientInfo, redirect_uri: str, state: Optional[str] = None,
|
|
93
|
+
code_challenge: Optional[str] = None) -> str:
|
|
80
94
|
"""
|
|
81
95
|
Build authorization url for browser
|
|
82
96
|
|
|
83
97
|
:param client_info: Info about oauth2 client
|
|
84
98
|
:param redirect_uri: Callback URL
|
|
99
|
+
:param state: Opaque value to protect the callback against forged requests. Providers echo it back on the redirect.
|
|
100
|
+
:param code_challenge: PKCE S256 code challenge, see generate_pkce_pair
|
|
85
101
|
:return: Ready to use URL
|
|
86
102
|
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
103
|
+
params = {
|
|
104
|
+
"client_id": client_info.client_id,
|
|
105
|
+
"redirect_uri": redirect_uri,
|
|
106
|
+
"scope": " ".join(client_info.scopes),
|
|
107
|
+
"response_type": "code",
|
|
108
|
+
}
|
|
109
|
+
if state is not None:
|
|
110
|
+
params["state"] = state
|
|
111
|
+
if code_challenge is not None:
|
|
112
|
+
params["code_challenge"] = code_challenge
|
|
113
|
+
params["code_challenge_method"] = "S256"
|
|
114
|
+
return f"{client_info.authorization_url}?{urllib.parse.urlencode(params)}"
|
|
92
115
|
|
|
93
116
|
|
|
94
117
|
def exchange_code_for_response(
|
|
95
118
|
client_info: OAuth2ClientInfo,
|
|
96
119
|
redirect_uri: str,
|
|
97
120
|
code: str,
|
|
121
|
+
code_verifier: Optional[str] = None,
|
|
98
122
|
) -> dict:
|
|
99
123
|
"""
|
|
100
124
|
Exchange a code for an access token using the endpoints from client info and return the entire response
|
|
@@ -102,6 +126,7 @@ def exchange_code_for_response(
|
|
|
102
126
|
:param client_info: Info about oauth2 client
|
|
103
127
|
:param redirect_uri: Callback URL
|
|
104
128
|
:param code: Code to redeem
|
|
129
|
+
:param code_verifier: PKCE code verifier matching the code challenge of the authorization request
|
|
105
130
|
:return: Response from OAuth2 endpoint
|
|
106
131
|
"""
|
|
107
132
|
headers = {
|
|
@@ -114,6 +139,8 @@ def exchange_code_for_response(
|
|
|
114
139
|
"redirect_uri": redirect_uri,
|
|
115
140
|
"grant_type": "authorization_code",
|
|
116
141
|
}
|
|
142
|
+
if code_verifier is not None:
|
|
143
|
+
data["code_verifier"] = code_verifier
|
|
117
144
|
encoded_data = urllib.parse.urlencode(data).encode('utf-8')
|
|
118
145
|
|
|
119
146
|
request = urllib.request.Request(client_info.token_url, data=encoded_data, headers=headers)
|
|
@@ -152,7 +179,8 @@ def exchange_code_for_access_token(
|
|
|
152
179
|
client_info: OAuth2ClientInfo,
|
|
153
180
|
redirect_uri: str,
|
|
154
181
|
code: str,
|
|
155
|
-
access_token_field: str = "access_token"
|
|
182
|
+
access_token_field: str = "access_token",
|
|
183
|
+
code_verifier: Optional[str] = None,
|
|
156
184
|
) -> str:
|
|
157
185
|
"""
|
|
158
186
|
Exchange a code for an access token using the endpoints from client info
|
|
@@ -162,8 +190,9 @@ def exchange_code_for_access_token(
|
|
|
162
190
|
:param code: Code to redeem
|
|
163
191
|
:param access_token_field: Name of the field containing the access token to use. This might differ depending on
|
|
164
192
|
the provider you are using. For example for Auth0 you have to set this to id_token
|
|
193
|
+
:param code_verifier: PKCE code verifier matching the code challenge of the authorization request
|
|
165
194
|
:return: Extracted access token from response
|
|
166
195
|
"""
|
|
167
|
-
json_response = exchange_code_for_response(client_info, redirect_uri, code)
|
|
196
|
+
json_response = exchange_code_for_response(client_info, redirect_uri, code, code_verifier)
|
|
168
197
|
|
|
169
198
|
return json_response.get(access_token_field)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Local HTTP server logic
|
|
3
3
|
"""
|
|
4
|
+
import hmac
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
4
7
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
5
8
|
from string import Template
|
|
6
9
|
from typing import Optional
|
|
@@ -198,6 +201,15 @@ class OAuthRedirectHandler(BaseHTTPRequestHandler):
|
|
|
198
201
|
)
|
|
199
202
|
self._serve_html(rendered_callback_page)
|
|
200
203
|
|
|
204
|
+
def _state_matches(self, params: dict) -> bool:
|
|
205
|
+
"""
|
|
206
|
+
Check the state parameter of the callback against the state of the running flow (constant time)
|
|
207
|
+
"""
|
|
208
|
+
received = params.get("state", [])
|
|
209
|
+
if len(received) != 1:
|
|
210
|
+
return False
|
|
211
|
+
return hmac.compare_digest(received[0].encode("utf-8"), self.server.state.encode("utf-8"))
|
|
212
|
+
|
|
201
213
|
# noinspection PyPep8Naming
|
|
202
214
|
def do_GET(self) -> None:
|
|
203
215
|
"""
|
|
@@ -207,7 +219,8 @@ class OAuthRedirectHandler(BaseHTTPRequestHandler):
|
|
|
207
219
|
|
|
208
220
|
has_error = ("code" not in params
|
|
209
221
|
or len(params['code']) != 1
|
|
210
|
-
or params['code'][0].strip() == ""
|
|
222
|
+
or params['code'][0].strip() == ""
|
|
223
|
+
or not self._state_matches(params))
|
|
211
224
|
|
|
212
225
|
if has_error:
|
|
213
226
|
self.send_response(400)
|
|
@@ -231,10 +244,18 @@ class OAuthCallbackHttpServer(HTTPServer):
|
|
|
231
244
|
Simplistic HTTP Server to provide local callback URL for oauth2 provider
|
|
232
245
|
"""
|
|
233
246
|
|
|
234
|
-
def __init__(self, port):
|
|
235
|
-
|
|
247
|
+
def __init__(self, port, host: str = "127.0.0.1", state: Optional[str] = None):
|
|
248
|
+
"""
|
|
249
|
+
:param port: Port to listen on
|
|
250
|
+
:param host: Address to bind to. Defaults to loopback so the callback is not reachable from the network.
|
|
251
|
+
:param state: Value for the OAuth2 state parameter. A random one is generated if omitted.
|
|
252
|
+
Callbacks not carrying exactly this state are rejected.
|
|
253
|
+
"""
|
|
254
|
+
super().__init__((host, port), OAuthRedirectHandler)
|
|
236
255
|
|
|
237
256
|
self._code: Optional[str] = None
|
|
257
|
+
self.state: str = state if state is not None else secrets.token_urlsafe(32)
|
|
258
|
+
"""State that has to be sent with the authorization request and is expected on the callback"""
|
|
238
259
|
|
|
239
260
|
def get_code(self) -> Optional[str]:
|
|
240
261
|
"""
|
|
@@ -262,12 +283,15 @@ class OAuthCallbackHttpServer(HTTPServer):
|
|
|
262
283
|
:param timeout_per_attempt: Timeout for each attempt to be successful
|
|
263
284
|
:return: Code from callback page or None if the callback page is not called successfully
|
|
264
285
|
"""
|
|
265
|
-
|
|
286
|
+
deadline = time.monotonic() + attempts * timeout_per_attempt
|
|
287
|
+
while True:
|
|
288
|
+
remaining = int(deadline - time.monotonic())
|
|
289
|
+
if remaining < 1:
|
|
290
|
+
return None
|
|
266
291
|
try:
|
|
267
|
-
_method_with_timeout(self.handle_request, timeout_seconds=timeout_per_attempt)
|
|
292
|
+
_method_with_timeout(self.handle_request, timeout_seconds=min(timeout_per_attempt, remaining))
|
|
268
293
|
except TimeoutException:
|
|
269
294
|
continue
|
|
295
|
+
# requests without a valid code/state are rejected by the handler and do not end the wait
|
|
270
296
|
if self.get_code() is not None:
|
|
271
297
|
return self.get_code()
|
|
272
|
-
|
|
273
|
-
return None
|
|
@@ -3,6 +3,7 @@ Ready to use workflows
|
|
|
3
3
|
"""
|
|
4
4
|
from oauth2_cli_auth import OAuthCallbackHttpServer, get_auth_url, exchange_code_for_access_token, OAuth2ClientInfo, \
|
|
5
5
|
open_browser
|
|
6
|
+
from oauth2_cli_auth.code_grant import generate_pkce_pair
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def get_access_token_with_browser_open(client_info: OAuth2ClientInfo, server_port: int = 8080) -> str:
|
|
@@ -19,9 +20,12 @@ def get_access_token_with_browser_open(client_info: OAuth2ClientInfo, server_por
|
|
|
19
20
|
:return: Access Token
|
|
20
21
|
"""
|
|
21
22
|
callback_server = OAuthCallbackHttpServer(server_port)
|
|
22
|
-
|
|
23
|
+
code_verifier, code_challenge = generate_pkce_pair()
|
|
24
|
+
auth_url = get_auth_url(client_info, callback_server.callback_url, state=callback_server.state,
|
|
25
|
+
code_challenge=code_challenge)
|
|
23
26
|
open_browser(auth_url)
|
|
24
27
|
code = callback_server.wait_for_code()
|
|
25
28
|
if code is None:
|
|
26
29
|
raise ValueError("No code could be obtained from browser callback page")
|
|
27
|
-
return exchange_code_for_access_token(client_info, callback_server.callback_url, code
|
|
30
|
+
return exchange_code_for_access_token(client_info, callback_server.callback_url, code,
|
|
31
|
+
code_verifier=code_verifier)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "oauth2-cli-auth"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.0.0"
|
|
4
4
|
description = "Authenticate against OAuth2 Provider in Python CLIs"
|
|
5
5
|
authors = ["Timo Reymann <mail@timo-reymann.de>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -37,9 +37,9 @@ url = "https://test.pypi.org/legacy/"
|
|
|
37
37
|
priority="explicit"
|
|
38
38
|
|
|
39
39
|
[tool.poetry.group.dev.dependencies]
|
|
40
|
-
pytest = "^
|
|
40
|
+
pytest = "^9.0.0"
|
|
41
41
|
coverage = "^7.3.2"
|
|
42
|
-
setuptools = "^
|
|
42
|
+
setuptools = "^84.0.0"
|
|
43
43
|
pydoctor = "^24.0.0"
|
|
44
44
|
|
|
45
45
|
[tool.coverage.run]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.0.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|