oauth2-cli-auth 1.1.0__tar.gz → 1.3.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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: oauth2-cli-auth
3
- Version: 1.1.0
3
+ Version: 1.3.0
4
4
  Summary: Authenticate against OAuth2 Provider in Python CLIs
5
5
  Home-page: https://github.com/timo-reymann/python-oauth2-cli-auth
6
6
  License: MIT
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3
19
19
  Classifier: Programming Language :: Python :: 3.9
20
20
  Classifier: Programming Language :: Python :: 3.10
21
21
  Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
22
23
  Project-URL: Bug Tracker, https://github.com/timo-reymann/python-oauth2-cli-auth/issues
23
24
  Project-URL: Repository, https://github.com/timo-reymann/python-oauth2-cli-auth
24
25
  Description-Content-Type: text/markdown
@@ -0,0 +1,23 @@
1
+ import signal
2
+
3
+
4
+ class TimeoutException(Exception):
5
+ pass
6
+
7
+
8
+ def _timeout_handler(signum, frame):
9
+ raise TimeoutException("Operation timed out")
10
+
11
+
12
+ def _method_with_timeout(your_method, timeout_seconds=5, *args, **kwargs):
13
+ signal.signal(signal.SIGALRM, _timeout_handler)
14
+ signal.alarm(timeout_seconds)
15
+
16
+ try:
17
+ result = your_method(*args, **kwargs)
18
+ except TimeoutException as te:
19
+ raise te
20
+ finally:
21
+ signal.alarm(0) # Reset the alarm
22
+
23
+ return result
@@ -0,0 +1,26 @@
1
+ import json
2
+ import time
3
+ import urllib
4
+ from urllib.error import URLError
5
+
6
+
7
+ def _urlopen_with_backoff(url, max_retries=3, base_delay=1, timeout=15):
8
+ retries = 0
9
+
10
+ while retries < max_retries:
11
+ try:
12
+ response = urllib.request.urlopen(url, timeout=timeout)
13
+ return response
14
+ except Exception:
15
+ retries += 1
16
+ delay = base_delay * (2 ** retries)
17
+ time.sleep(delay)
18
+
19
+ raise URLError(f"Failed to open URL after {max_retries} retries")
20
+
21
+
22
+ def _load_json(url_or_request: str | urllib.request.Request) -> dict:
23
+ with _urlopen_with_backoff(url_or_request) as response:
24
+ response_data = response.read().decode('utf-8')
25
+ json_response = json.loads(response_data)
26
+ return json_response
@@ -1,9 +1,11 @@
1
+ import base64
2
+ import urllib.parse
3
+ import urllib.request
1
4
  import webbrowser
5
+ from collections.abc import Callable
2
6
  from dataclasses import dataclass
3
- import urllib.request
4
- import urllib.parse
5
- import base64
6
- import json
7
+
8
+ from oauth2_cli_auth._urllib_util import _load_json
7
9
 
8
10
 
9
11
  @dataclass
@@ -31,25 +33,20 @@ class OAuth2ClientInfo:
31
33
  )
32
34
 
33
35
 
34
- def _load_json(url_or_request: str | urllib.request.Request) -> dict:
35
- with urllib.request.urlopen(url_or_request) as response:
36
- response_data = response.read().decode('utf-8')
37
- json_response = json.loads(response_data)
38
- return json_response
39
-
40
-
41
36
  def load_oidc_config(odic_well_known_endpoint: str) -> dict:
42
37
  config = _load_json(odic_well_known_endpoint)
43
38
  return config
44
39
 
45
40
 
46
- def open_browser(url: str) -> None:
41
+ def open_browser(url: str, print_open_browser_instruction: Callable[[str], None] | None = print) -> None:
47
42
  """
48
43
  Open browser using webbrowser module and show message about URL open
44
+ :param print_open_browser_instruction: Callback to print the instructions to open the browser. Set to None in order to supress the output.
49
45
  :param url: URL to open and display
50
46
  :return: None
51
47
  """
52
- print(f"Open your browser at\n{url}")
48
+ if print_open_browser_instruction is not None:
49
+ print_open_browser_instruction(f"Open your browser at\n{url}")
53
50
  webbrowser.open(url)
54
51
 
55
52
 
@@ -68,17 +65,20 @@ def get_auth_url(client_info: OAuth2ClientInfo, redirect_uri: str) -> str:
68
65
  f"&response_type=code")
69
66
 
70
67
 
71
- def exchange_code_for_access_token(client_info: OAuth2ClientInfo, redirect_uri: str, code: str,
72
- access_token_field: str = "access_token") -> str:
68
+ def exchange_code_for_response(
69
+ client_info: OAuth2ClientInfo,
70
+ redirect_uri: str,
71
+ code: str,
72
+ ) -> dict:
73
73
  """
74
- Exchange a code for an access token using the endpoints from client info
74
+ Exchange a code for an access token using the endpoints from client info and return the entire response
75
75
 
76
76
  :param client_info: Info about oauth2 client
77
77
  :param redirect_uri: Callback URL
78
78
  :param code: Code to redeem
79
79
  :param access_token_field: Name of the field containing the access token to use. This might differ depending on
80
- the provider you are using. For example for Auth0 you have to set this to id_token
81
- :return: Extracted access token from response
80
+ the provider you are using. For example for Auth0 you have to set this to id_token
81
+ :return: Response from OAuth2 endpoint
82
82
  """
83
83
  headers = {
84
84
  "Content-Type": "application/x-www-form-urlencoded",
@@ -95,4 +95,25 @@ def exchange_code_for_access_token(client_info: OAuth2ClientInfo, redirect_uri:
95
95
  request = urllib.request.Request(client_info.token_url, data=encoded_data, headers=headers)
96
96
  json_response = _load_json(request)
97
97
 
98
+ return json_response
99
+
100
+
101
+ def exchange_code_for_access_token(
102
+ client_info: OAuth2ClientInfo,
103
+ redirect_uri: str,
104
+ code: str,
105
+ access_token_field: str = "access_token"
106
+ ) -> str:
107
+ """
108
+ Exchange a code for an access token using the endpoints from client info
109
+
110
+ :param client_info: Info about oauth2 client
111
+ :param redirect_uri: Callback URL
112
+ :param code: Code to redeem
113
+ :param access_token_field: Name of the field containing the access token to use. This might differ depending on
114
+ the provider you are using. For example for Auth0 you have to set this to id_token
115
+ :return: Extracted access token from response
116
+ """
117
+ json_response = exchange_code_for_response(client_info, redirect_uri, code)
118
+
98
119
  return json_response.get(access_token_field)
@@ -0,0 +1,12 @@
1
+ import urllib
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+
6
+
7
+ @pytest.fixture
8
+ def create_urlopen_mock():
9
+ def mock(buffer):
10
+ return patch.object(urllib.request, 'urlopen', return_value=buffer)
11
+
12
+ return mock
@@ -2,6 +2,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
2
2
  from string import Template
3
3
  from typing import Optional
4
4
  from urllib.parse import parse_qs, urlparse
5
+ from oauth2_cli_auth._timeout import _method_with_timeout, TimeoutException
5
6
 
6
7
 
7
8
  class CallbackPageTemplate:
@@ -150,10 +151,13 @@ class CallbackPageTemplate:
150
151
  class OAuthRedirectHandler(BaseHTTPRequestHandler):
151
152
  callback_template = CallbackPageTemplate()
152
153
 
154
+ def log_message(self, format, *args):
155
+ # silence the log messages
156
+ pass
157
+
153
158
  def do_GET(self):
154
159
  params = parse_qs(urlparse(self.path).query)
155
160
 
156
-
157
161
  has_error = "code" not in params or len(params['code']) != 1 or params['code'][0].strip() == ""
158
162
 
159
163
  if has_error:
@@ -197,9 +201,22 @@ class OAuthCallbackHttpServer(HTTPServer):
197
201
  def callback_url(self):
198
202
  return f"http://localhost:{self.server_port}"
199
203
 
200
- def wait_for_code(self, attempts: int = 3) -> Optional[int]:
204
+ def wait_for_code(self, attempts: int = 3, timeout_per_attempt=10) -> Optional[str]:
205
+ """
206
+ Wait for the server to open the callback page containing the code query parameter.
207
+
208
+ It tries for #attempts with a timeout of #timeout_per_attempts for each attempt.
209
+ This prevents the CLI from getting stuck by unsolved callback URls
210
+
211
+ :param attempts: Amount of attempts
212
+ :param timeout_per_attempt: Timeout for each attempt to be successful
213
+ :return: Code from callback page or None if the callback page is not called successfully
214
+ """
201
215
  for i in range(0, attempts):
202
- self.handle_request()
216
+ try:
217
+ _method_with_timeout(self.handle_request, timeout_seconds=timeout_per_attempt)
218
+ except TimeoutException:
219
+ continue
203
220
  if self.get_code() is not None:
204
221
  return self.get_code()
205
222
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "oauth2-cli-auth"
3
- version = "1.1.0"
3
+ version = "1.3.0"
4
4
  description = "Authenticate against OAuth2 Provider in Python CLIs"
5
5
  authors = ["Timo Reymann <mail@timo-reymann.de>"]
6
6
  license = "MIT"
@@ -34,8 +34,7 @@ python = "^3.9"
34
34
  [[tool.poetry.source]]
35
35
  name = "testpypi"
36
36
  url = "https://test.pypi.org/legacy/"
37
- default = false
38
- secondary = false
37
+ priority="explicit"
39
38
 
40
39
  [tool.poetry.group.dev.dependencies]
41
40
  pytest = "^7.4.3"
@@ -1,118 +0,0 @@
1
- <html lang="en">
2
-
3
- <head>
4
- <title>bcs</title>
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
- <meta name="charset" content="utf-8">
7
- <style>
8
- * {
9
- margin: 0;
10
- padding: 0;
11
- }
12
-
13
- body {
14
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
15
- }
16
-
17
- html, body {
18
- display: flex;
19
- align-items: center;
20
- justify-content: center;
21
- height: 100%;
22
- }
23
-
24
- .message {
25
- text-align: center;
26
- padding: 40px;
27
- }
28
-
29
- .animation-ctn {
30
- text-align: center;
31
- margin-top: 5em;
32
- }
33
-
34
- @keyframes checkmark {
35
- 0% {
36
- stroke-dashoffset: 100px
37
- }
38
-
39
- 100% {
40
- stroke-dashoffset: 0px
41
- }
42
- }
43
-
44
- @keyframes checkmark-circle {
45
- 0% {
46
- stroke-dashoffset: 480px
47
- }
48
-
49
- 100% {
50
- stroke-dashoffset: 960px
51
- }
52
- }
53
-
54
- @keyframes colored-circle {
55
- 0% {
56
- opacity: 0
57
- }
58
-
59
- 100% {
60
- opacity: 100
61
- }
62
- }
63
-
64
-
65
- .icon svg polyline {
66
- -webkit-animation: checkmark 0.25s ease-in-out 0.7s backwards;
67
- animation: checkmark 0.25s ease-in-out 0.7s backwards
68
- }
69
-
70
- .icon svg circle {
71
- -webkit-animation: checkmark-circle 0.6s ease-in-out backwards;
72
- animation: checkmark-circle 0.6s ease-in-out backwards;
73
- }
74
-
75
- .icon svg circle#colored {
76
- -webkit-animation: colored-circle 0.6s ease-in-out 0.7s backwards;
77
- animation: colored-circle 0.6s ease-in-out 0.7s backwards;
78
- }
79
- </style>
80
- </head>
81
-
82
- <body>
83
- <div class="message">
84
- <div class="animation-ctn">
85
- <div class="icon">
86
- <svg xmlns="http://www.w3.org/2000/svg" width="154px" height="154px">
87
- <g fill="none" stroke="#22AE73" stroke-width="2">
88
- <circle cx="77" cy="77" r="72"
89
- style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
90
- <circle id="colored" fill="#22AE73" cx="77" cy="77" r="72"
91
- style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
92
- <polyline class="st0" stroke="#fff" stroke-width="10" points="43.5,77.8 63.7,97.9 112.2,49.4 "
93
- style="stroke-dasharray:100px, 100px; stroke-dashoffset: 200px;"/>
94
- </g>
95
- </svg>
96
- </div>
97
- </div>
98
-
99
- <div class="animation-ctn">
100
- <div class="icon">
101
- <svg xmlns="http://www.w3.org/2000/svg" width="154px" height="154px">
102
- <g fill="none" stroke="#F44812" stroke-width="2">
103
- <circle cx="77" cy="77" r="72"
104
- style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
105
- <circle id="colored" fill="#F44812" cx="77" cy="77" r="72"
106
- style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
107
- <polyline class="st0" stroke="#fff" stroke-width="10" points="43.5,77.8 112.2,77.8 "
108
- style="stroke-dasharray:100px, 100px; stroke-dashoffset: 200px;"/>
109
- </g>
110
- </svg>
111
- </div>
112
- </div>
113
-
114
- <h1>$title</h1>
115
- <p>$message</p>
116
- </div>
117
- </body>
118
- </html>
File without changes