oauth2-cli-auth 1.6.2__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-1.6.2 → oauth2_cli_auth-3.0.0}/PKG-INFO +6 -4
- {oauth2_cli_auth-1.6.2 → 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-1.6.2 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/code_grant.py +69 -10
- {oauth2_cli_auth-1.6.2 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/http_server.py +31 -7
- {oauth2_cli_auth-1.6.2 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/simplified_flow.py +6 -2
- {oauth2_cli_auth-1.6.2 → oauth2_cli_auth-3.0.0}/pyproject.toml +4 -4
- oauth2_cli_auth-1.6.2/oauth2_cli_auth/__version__.py +0 -1
- {oauth2_cli_auth-1.6.2 → oauth2_cli_auth-3.0.0}/LICENSE +0 -0
- {oauth2_cli_auth-1.6.2 → oauth2_cli_auth-3.0.0}/README.md +0 -0
- {oauth2_cli_auth-1.6.2 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/_timeout.py +0 -0
- {oauth2_cli_auth-1.6.2 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/_urllib_util.py +0 -0
- {oauth2_cli_auth-1.6.2 → oauth2_cli_auth-3.0.0}/oauth2_cli_auth/conftest.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
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
|
-
Requires-Python: >=3.
|
|
9
|
+
Requires-Python: >=3.10
|
|
9
10
|
Classifier: Development Status :: 5 - Production/Stable
|
|
10
11
|
Classifier: Environment :: Console
|
|
11
12
|
Classifier: Environment :: MacOS X
|
|
@@ -15,11 +16,12 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
15
16
|
Classifier: Operating System :: POSIX
|
|
16
17
|
Classifier: Operating System :: Unix
|
|
17
18
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
23
25
|
Project-URL: Bug Tracker, https://github.com/timo-reymann/python-oauth2-cli-auth/issues
|
|
24
26
|
Project-URL: Repository, https://github.com/timo-reymann/python-oauth2-cli-auth
|
|
25
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, exchange_code_for_access_token, 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
|
|
@@ -26,11 +28,14 @@ class OAuth2ClientInfo:
|
|
|
26
28
|
client_id: str
|
|
27
29
|
"""Id of the client to request for"""
|
|
28
30
|
|
|
31
|
+
client_secret: str | None
|
|
32
|
+
"""Secret of the client to request for"""
|
|
33
|
+
|
|
29
34
|
scopes: list[str]
|
|
30
35
|
"""List of scopes to request"""
|
|
31
36
|
|
|
32
37
|
@staticmethod
|
|
33
|
-
def from_oidc_endpoint(oidc_config_endpoint: str, client_id: str, scopes: list[str]) -> "OAuth2ClientInfo":
|
|
38
|
+
def from_oidc_endpoint(oidc_config_endpoint: str, client_id: str, scopes: list[str] = [], client_secret: str | None = None) -> "OAuth2ClientInfo":
|
|
34
39
|
"""
|
|
35
40
|
Create client information object from well known endpoint in format as specified in
|
|
36
41
|
https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
|
@@ -45,6 +50,7 @@ class OAuth2ClientInfo:
|
|
|
45
50
|
authorization_url=config.get("authorization_endpoint"),
|
|
46
51
|
token_url=config.get("token_endpoint"),
|
|
47
52
|
client_id=client_id,
|
|
53
|
+
client_secret=client_secret,
|
|
48
54
|
scopes=scopes,
|
|
49
55
|
)
|
|
50
56
|
|
|
@@ -72,25 +78,47 @@ def open_browser(url: str, print_open_browser_instruction: Optional[Callable[[st
|
|
|
72
78
|
webbrowser.open(url)
|
|
73
79
|
|
|
74
80
|
|
|
75
|
-
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:
|
|
76
94
|
"""
|
|
77
95
|
Build authorization url for browser
|
|
78
96
|
|
|
79
97
|
:param client_info: Info about oauth2 client
|
|
80
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
|
|
81
101
|
:return: Ready to use URL
|
|
82
102
|
"""
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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)}"
|
|
88
115
|
|
|
89
116
|
|
|
90
117
|
def exchange_code_for_response(
|
|
91
118
|
client_info: OAuth2ClientInfo,
|
|
92
119
|
redirect_uri: str,
|
|
93
120
|
code: str,
|
|
121
|
+
code_verifier: Optional[str] = None,
|
|
94
122
|
) -> dict:
|
|
95
123
|
"""
|
|
96
124
|
Exchange a code for an access token using the endpoints from client info and return the entire response
|
|
@@ -98,11 +126,12 @@ def exchange_code_for_response(
|
|
|
98
126
|
:param client_info: Info about oauth2 client
|
|
99
127
|
:param redirect_uri: Callback URL
|
|
100
128
|
:param code: Code to redeem
|
|
129
|
+
:param code_verifier: PKCE code verifier matching the code challenge of the authorization request
|
|
101
130
|
:return: Response from OAuth2 endpoint
|
|
102
131
|
"""
|
|
103
132
|
headers = {
|
|
104
133
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
105
|
-
"Authorization": "Basic " + base64.b64encode(f"{client_info.client_id}:".encode()).decode(),
|
|
134
|
+
"Authorization": "Basic " + base64.b64encode(f"{client_info.client_id}:{client_info.client_secret if client_info.client_secret is not None else ''}".encode()).decode(),
|
|
106
135
|
}
|
|
107
136
|
|
|
108
137
|
data = {
|
|
@@ -110,6 +139,8 @@ def exchange_code_for_response(
|
|
|
110
139
|
"redirect_uri": redirect_uri,
|
|
111
140
|
"grant_type": "authorization_code",
|
|
112
141
|
}
|
|
142
|
+
if code_verifier is not None:
|
|
143
|
+
data["code_verifier"] = code_verifier
|
|
113
144
|
encoded_data = urllib.parse.urlencode(data).encode('utf-8')
|
|
114
145
|
|
|
115
146
|
request = urllib.request.Request(client_info.token_url, data=encoded_data, headers=headers)
|
|
@@ -117,12 +148,39 @@ def exchange_code_for_response(
|
|
|
117
148
|
|
|
118
149
|
return json_response
|
|
119
150
|
|
|
151
|
+
def refresh_access_token(
|
|
152
|
+
client_info: OAuth2ClientInfo,
|
|
153
|
+
refresh_token: str,
|
|
154
|
+
) -> dict:
|
|
155
|
+
"""
|
|
156
|
+
Refresh an access token using the endpoints from client info
|
|
157
|
+
|
|
158
|
+
:param client_info: Info about oauth2 client
|
|
159
|
+
:param refresh_token: Refresh token to use
|
|
160
|
+
:return: Response from OAuth2 endpoint
|
|
161
|
+
"""
|
|
162
|
+
headers = {
|
|
163
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
164
|
+
"Authorization": "Basic " + base64.b64encode(f"{client_info.client_id}:{client_info.client_secret if client_info.client_secret is not None else ''}".encode()).decode(),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
data = {
|
|
168
|
+
"refresh_token": refresh_token,
|
|
169
|
+
"grant_type": "refresh_token",
|
|
170
|
+
}
|
|
171
|
+
encoded_data = urllib.parse.urlencode(data).encode('utf-8')
|
|
172
|
+
|
|
173
|
+
request = urllib.request.Request(client_info.token_url, data=encoded_data, headers=headers)
|
|
174
|
+
json_response = _load_json(request)
|
|
175
|
+
|
|
176
|
+
return json_response
|
|
120
177
|
|
|
121
178
|
def exchange_code_for_access_token(
|
|
122
179
|
client_info: OAuth2ClientInfo,
|
|
123
180
|
redirect_uri: str,
|
|
124
181
|
code: str,
|
|
125
|
-
access_token_field: str = "access_token"
|
|
182
|
+
access_token_field: str = "access_token",
|
|
183
|
+
code_verifier: Optional[str] = None,
|
|
126
184
|
) -> str:
|
|
127
185
|
"""
|
|
128
186
|
Exchange a code for an access token using the endpoints from client info
|
|
@@ -132,8 +190,9 @@ def exchange_code_for_access_token(
|
|
|
132
190
|
:param code: Code to redeem
|
|
133
191
|
:param access_token_field: Name of the field containing the access token to use. This might differ depending on
|
|
134
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
|
|
135
194
|
:return: Extracted access token from response
|
|
136
195
|
"""
|
|
137
|
-
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)
|
|
138
197
|
|
|
139
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"
|
|
@@ -26,7 +26,7 @@ include = [
|
|
|
26
26
|
"LICENSE"
|
|
27
27
|
]
|
|
28
28
|
[tool.poetry.dependencies]
|
|
29
|
-
python = ">=3.
|
|
29
|
+
python = ">=3.10"
|
|
30
30
|
|
|
31
31
|
[tool.poetry.urls]
|
|
32
32
|
"Bug Tracker" = "https://github.com/timo-reymann/python-oauth2-cli-auth/issues"
|
|
@@ -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__ = "1.6.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|