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.
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: oauth2-cli-auth
3
- Version: 2.0.0
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 get_auth_url(client_info: OAuth2ClientInfo, redirect_uri: str) -> str:
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
- return (f"{client_info.authorization_url}"
88
- f"?client_id={client_info.client_id}"
89
- f"&redirect_uri={redirect_uri}"
90
- f"&scope={'+'.join(client_info.scopes)}"
91
- f"&response_type=code")
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
- super().__init__(("", port), OAuthRedirectHandler)
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
- for _ in range(0, attempts):
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
- auth_url = get_auth_url(client_info, callback_server.callback_url)
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 = "2.0.0"
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 = "^8.0.0"
40
+ pytest = "^9.0.0"
41
41
  coverage = "^7.3.2"
42
- setuptools = "^80.0.0"
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