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.
@@ -1,11 +1,12 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: oauth2-cli-auth
3
- Version: 1.6.2
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
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 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:
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
- return (f"{client_info.authorization_url}"
84
- f"?client_id={client_info.client_id}"
85
- f"&redirect_uri={redirect_uri}"
86
- f"&scope={'+'.join(client_info.scopes)}"
87
- 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)}"
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
- 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 = "1.6.2"
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.9"
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 = "^8.0.0"
40
+ pytest = "^9.0.0"
41
41
  coverage = "^7.3.2"
42
- setuptools = "^78.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__ = "1.6.2"
File without changes