oauth2-cli-auth 1.3.0__tar.gz → 1.5.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,12 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: oauth2-cli-auth
3
- Version: 1.3.0
3
+ Version: 1.5.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
7
7
  Author: Timo Reymann
8
8
  Author-email: mail@timo-reymann.de
9
- Requires-Python: >=3.9,<4.0
9
+ Requires-Python: >=3.9
10
10
  Classifier: Development Status :: 5 - Production/Stable
11
11
  Classifier: Environment :: Console
12
12
  Classifier: Environment :: MacOS X
@@ -111,7 +111,7 @@ So I created this small library without any dependencies besides the python stan
111
111
 
112
112
  ## Documentation
113
113
 
114
- - [Library documentation](https://timo-reymann.github.io/python-oauth2-cli-auth/oauth2_cli_auth/)
114
+ - [Library documentation](https://timo-reymann.github.io/python-oauth2-cli-auth)
115
115
 
116
116
  ## Contributing
117
117
 
@@ -85,7 +85,7 @@ So I created this small library without any dependencies besides the python stan
85
85
 
86
86
  ## Documentation
87
87
 
88
- - [Library documentation](https://timo-reymann.github.io/python-oauth2-cli-auth/oauth2_cli_auth/)
88
+ - [Library documentation](https://timo-reymann.github.io/python-oauth2-cli-auth)
89
89
 
90
90
  ## Contributing
91
91
 
@@ -1,3 +1,6 @@
1
+ """
2
+ Functionality to work with OAuth2 code grant
3
+ """
1
4
  import base64
2
5
  import urllib.parse
3
6
  import urllib.request
@@ -15,15 +18,27 @@ class OAuth2ClientInfo:
15
18
  """
16
19
  authorization_url: str
17
20
  """Authorization URL to redirect the user to"""
21
+
18
22
  token_url: str
19
23
  """Token URL for fetching the access token"""
24
+
20
25
  client_id: str
21
26
  """Id of the client to request for"""
27
+
22
28
  scopes: list[str]
23
29
  """List of scopes to request"""
24
30
 
25
31
  @staticmethod
26
- def from_oidc_endpoint(oidc_config_endpoint: str, client_id: str, scopes: list[str]):
32
+ def from_oidc_endpoint(oidc_config_endpoint: str, client_id: str, scopes: list[str]) -> "OAuth2ClientInfo":
33
+ """
34
+ Create client information object from well known endpoint in format as specified in
35
+ https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
36
+
37
+ :param oidc_config_endpoint: Endpoint to load
38
+ :param client_id: Client ID to use
39
+ :param scopes: Scopes to add to OAuth2 authorization requests
40
+ :return: OAuth2ClientInfo containing parsed information from endpoint
41
+ """
27
42
  config = load_oidc_config(oidc_config_endpoint)
28
43
  return OAuth2ClientInfo(
29
44
  authorization_url=config.get("authorization_endpoint"),
@@ -34,6 +49,11 @@ class OAuth2ClientInfo:
34
49
 
35
50
 
36
51
  def load_oidc_config(odic_well_known_endpoint: str) -> dict:
52
+ """
53
+ Load OIDC configuration from the well known configuration endpoint
54
+
55
+ :param odic_well_known_endpoint: Endpoint to load configuration from
56
+ """
37
57
  config = _load_json(odic_well_known_endpoint)
38
58
  return config
39
59
 
@@ -41,6 +61,7 @@ def load_oidc_config(odic_well_known_endpoint: str) -> dict:
41
61
  def open_browser(url: str, print_open_browser_instruction: Callable[[str], None] | None = print) -> None:
42
62
  """
43
63
  Open browser using webbrowser module and show message about URL open
64
+
44
65
  :param print_open_browser_instruction: Callback to print the instructions to open the browser. Set to None in order to supress the output.
45
66
  :param url: URL to open and display
46
67
  :return: None
@@ -76,8 +97,6 @@ def exchange_code_for_response(
76
97
  :param client_info: Info about oauth2 client
77
98
  :param redirect_uri: Callback URL
78
99
  :param code: Code to redeem
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
100
  :return: Response from OAuth2 endpoint
82
101
  """
83
102
  headers = {
@@ -0,0 +1,273 @@
1
+ """
2
+ Local HTTP server logic
3
+ """
4
+ from http.server import BaseHTTPRequestHandler, HTTPServer
5
+ from string import Template
6
+ from typing import Optional
7
+ from urllib.parse import parse_qs, urlparse
8
+
9
+ from oauth2_cli_auth._timeout import TimeoutException, _method_with_timeout
10
+
11
+
12
+ class CallbackPageTemplate:
13
+ """
14
+ Holder for callback page assets and template
15
+ """
16
+ SUCCESS_SVG = """
17
+ <svg xmlns="http://www.w3.org/2000/svg" width="154px" height="154px">
18
+ <g fill="none" stroke="#22AE73" stroke-width="2">
19
+ <circle cx="77" cy="77" r="72" style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
20
+ <circle id="colored" fill="#22AE73" cx="77" cy="77" r="72" style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
21
+ <polyline class="st0" stroke="#fff" stroke-width="10" points="43.5,77.8 63.7,97.9 112.2,49.4 " style="stroke-dasharray:100px, 100px; stroke-dashoffset: 200px;"/>
22
+ </g>
23
+ </svg>
24
+ """
25
+ """SVG to display checkmark"""
26
+
27
+ ERROR_SVG = """
28
+ <svg xmlns="http://www.w3.org/2000/svg" width="154px" height="154px">
29
+ <g fill="none" stroke="#F44812" stroke-width="2">
30
+ <circle cx="77" cy="77" r="72" style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
31
+ <circle id="colored" fill="#F44812" cx="77" cy="77" r="72" style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
32
+ <polyline class="st0" stroke="#fff" stroke-width="10" points="43.5,77.8 112.2,77.8 " style="stroke-dasharray:100px, 100px; stroke-dashoffset: 200px;"/>
33
+ </g>
34
+ </svg>
35
+ """
36
+ """SVG to display error icon"""
37
+
38
+ PAGE_TEMPLATE = Template(
39
+ """
40
+ <html lang="$lang">
41
+ <head>
42
+ <title>$title</title>
43
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
44
+ <meta name="charset" content="utf-8">
45
+ <style>
46
+ * {
47
+ margin: 0;
48
+ padding: 0;
49
+ }
50
+
51
+ body {
52
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
53
+ }
54
+
55
+ @media (prefers-color-scheme: dark) {
56
+ body {
57
+ background: rgb(34, 39, 46);
58
+ color: rgb(173, 186, 199);
59
+ }
60
+ }
61
+
62
+ html, body {
63
+ display: flex;
64
+ align-items: center;
65
+ justify-content: center;
66
+ height: 100%;
67
+ }
68
+
69
+ h1 {
70
+ font-size: 4rem;
71
+ }
72
+
73
+ p {
74
+ font-size: 1.4rem;
75
+ max-width: 70ch;
76
+ }
77
+
78
+ .message {
79
+ text-align: center;
80
+ }
81
+
82
+ .animation-ctn {
83
+ text-align: center;
84
+ }
85
+
86
+ @keyframes checkmark {
87
+ 0% {
88
+ stroke-dashoffset: 100px
89
+ }
90
+
91
+ 100% {
92
+ stroke-dashoffset: 0px
93
+ }
94
+ }
95
+
96
+ @keyframes checkmark-circle {
97
+ 0% {
98
+ stroke-dashoffset: 480px
99
+ }
100
+
101
+ 100% {
102
+ stroke-dashoffset: 960px
103
+ }
104
+ }
105
+
106
+ @keyframes colored-circle {
107
+ 0% {
108
+ opacity: 0
109
+ }
110
+
111
+ 100% {
112
+ opacity: 100
113
+ }
114
+ }
115
+
116
+ .icon svg {
117
+ padding: 1rem;
118
+ }
119
+
120
+ .icon svg polyline {
121
+ -webkit-animation: checkmark 0.25s ease-in-out 0.7s backwards;
122
+ animation: checkmark 0.25s ease-in-out 0.7s backwards
123
+ }
124
+
125
+ .icon svg circle {
126
+ -webkit-animation: checkmark-circle 0.6s ease-in-out backwards;
127
+ animation: checkmark-circle 0.6s ease-in-out backwards;
128
+ }
129
+
130
+ .icon svg circle#colored {
131
+ -webkit-animation: colored-circle 0.6s ease-in-out 0.7s backwards;
132
+ animation: colored-circle 0.6s ease-in-out 0.7s backwards;
133
+ }
134
+ </style>
135
+ </head>
136
+
137
+ <body>
138
+ <div class="message">
139
+ <div class="animation-ctn">
140
+ <div class="icon">
141
+ $svg
142
+ </div>
143
+ </div>
144
+
145
+ <h1>$title</h1>
146
+ <p>$message</p>
147
+ </div>
148
+ </body>
149
+ </html>
150
+ """
151
+ )
152
+ """Template for callback HTML page"""
153
+
154
+ def render(self, title: str, message: str, lang: str = "en", has_error: bool = False):
155
+ """
156
+ Render callback page
157
+
158
+ :param title: Page title
159
+ :param message: Message to display under the icon
160
+ :param lang: Langauge for HTML tag
161
+ :param has_error: Indicates weather checkmark (false) or cross (true) should be displayed
162
+ :return: Rendered HTML
163
+ """
164
+ return self.PAGE_TEMPLATE.substitute(
165
+ lang=lang,
166
+ title=title,
167
+ message=message,
168
+ svg=self.ERROR_SVG if has_error else self.SUCCESS_SVG,
169
+ )
170
+
171
+
172
+ class OAuthRedirectHandler(BaseHTTPRequestHandler):
173
+ """
174
+ HTTPRequest Handler that is intended to be used as oauth2 callback page
175
+ """
176
+ _callback_template = CallbackPageTemplate()
177
+ LANGUAGE = "en"
178
+ """HTML language to render for callback page"""
179
+
180
+ def log_message(self, format, *args):
181
+ """
182
+ Disables logging for BaseHTTPRequestHandler
183
+ """
184
+ # silence the log messages
185
+ pass
186
+
187
+ def _serve_html(self, html: str):
188
+ self.send_header("Content-type", "text/html")
189
+ self.end_headers()
190
+ self.wfile.write(html.encode("utf-8"))
191
+
192
+ def _serve_callback_page(self, title: str, message: str, has_error: bool):
193
+ rendered_callback_page = self._callback_template.render(
194
+ lang=self.LANGUAGE,
195
+ title=title,
196
+ message=message,
197
+ has_error=has_error
198
+ )
199
+ self._serve_html(rendered_callback_page)
200
+
201
+ # noinspection PyPep8Naming
202
+ def do_GET(self) -> None:
203
+ """
204
+ Provides callback page for the oauth2 redirect
205
+ """
206
+ params = parse_qs(urlparse(self.path).query)
207
+
208
+ has_error = ("code" not in params
209
+ or len(params['code']) != 1
210
+ or params['code'][0].strip() == "")
211
+
212
+ if has_error:
213
+ self.send_response(400)
214
+ self._serve_callback_page(
215
+ title="Oh snap!",
216
+ message="Something went wrong trying to authenticate you. Please try going back in your browser, or restart the auth process.",
217
+ has_error=True,
218
+ )
219
+ else:
220
+ self.send_response(200)
221
+ self.server._code = params["code"][0]
222
+ self._serve_callback_page(
223
+ title="Success",
224
+ message="You have been authenticated successfully. You may close this browser window now and go back to the terminal",
225
+ has_error=False,
226
+ )
227
+
228
+
229
+ class OAuthCallbackHttpServer(HTTPServer):
230
+ """
231
+ Simplistic HTTP Server to provide local callback URL for oauth2 provider
232
+ """
233
+
234
+ def __init__(self, port):
235
+ super().__init__(("", port), OAuthRedirectHandler)
236
+
237
+ self._code: str | None = None
238
+
239
+ def get_code(self) -> str | None:
240
+ """
241
+ This method should only be called after the request was done and might be None when no token is given.
242
+
243
+ :return: Authorization code or None if the request was not performed yet
244
+ """
245
+ return self._code
246
+
247
+ @property
248
+ def callback_url(self) -> str:
249
+ """
250
+ Callback URL for the HTTP-Server
251
+ """
252
+ return f"http://localhost:{self.server_port}"
253
+
254
+ def wait_for_code(self, attempts: int = 3, timeout_per_attempt=10) -> Optional[str]:
255
+ """
256
+ Wait for the server to open the callback page containing the code query parameter.
257
+
258
+ It tries for #attempts with a timeout of #timeout_per_attempts for each attempt.
259
+ This prevents the CLI from getting stuck by unsolved callback URls
260
+
261
+ :param attempts: Amount of attempts
262
+ :param timeout_per_attempt: Timeout for each attempt to be successful
263
+ :return: Code from callback page or None if the callback page is not called successfully
264
+ """
265
+ for _ in range(0, attempts):
266
+ try:
267
+ _method_with_timeout(self.handle_request, timeout_seconds=timeout_per_attempt)
268
+ except TimeoutException:
269
+ continue
270
+ if self.get_code() is not None:
271
+ return self.get_code()
272
+
273
+ return None
@@ -1,3 +1,6 @@
1
+ """
2
+ Ready to use workflows
3
+ """
1
4
  from oauth2_cli_auth import OAuthCallbackHttpServer, get_auth_url, exchange_code_for_access_token, OAuth2ClientInfo, \
2
5
  open_browser
3
6
 
@@ -10,6 +13,7 @@ def get_access_token_with_browser_open(client_info: OAuth2ClientInfo, server_por
10
13
  - Open the browser with the authorization URL
11
14
  - Wait for the code to arrive
12
15
  - Get access token from code
16
+
13
17
  :param client_info: Client Info for Oauth2 Interaction
14
18
  :param server_port: Port of the local web server to spin up
15
19
  :return: Access Token
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "oauth2-cli-auth"
3
- version = "1.3.0"
3
+ version = "1.5.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.9"
30
30
 
31
31
  [tool.poetry.urls]
32
32
  "Bug Tracker" = "https://github.com/timo-reymann/python-oauth2-cli-auth/issues"
@@ -39,7 +39,8 @@ priority="explicit"
39
39
  [tool.poetry.group.dev.dependencies]
40
40
  pytest = "^7.4.3"
41
41
  coverage = "^7.3.2"
42
- pdoc3 = "^0.10.0"
42
+ setuptools = "^69.5.1"
43
+ pydoctor = "^23.9.1"
43
44
 
44
45
  [tool.coverage.run]
45
46
  omit = [".*", "*/site-packages/*", "*/*_test.py"]
@@ -50,3 +51,22 @@ fail_under = 70
50
51
  [build-system]
51
52
  requires = ["poetry-core"]
52
53
  build-backend = "poetry.core.masonry.api"
54
+
55
+ [tool.pydoctor]
56
+ verbose = 0
57
+ warnings-as-errors = true
58
+
59
+ intersphinx = ["https://docs.python.org/3/objects.inv"]
60
+ docformat = "restructuredtext"
61
+ html-output = "gh-pages"
62
+
63
+ add-package = ["oauth2_cli_auth"]
64
+ project-name = "oauth2_cli_auth"
65
+ project-url = "https://github.com/timo-reymann/python-oauth2-cli-auth"
66
+ privacy = [
67
+ "HIDDEN:oauth2_cli_auth.*_test",
68
+ "HIDDEN:oauth2_cli_auth.conftest"
69
+ ]
70
+
71
+ theme = "base"
72
+ template-dir = "pydoctor-theme"
@@ -1,223 +0,0 @@
1
- from http.server import BaseHTTPRequestHandler, HTTPServer
2
- from string import Template
3
- from typing import Optional
4
- from urllib.parse import parse_qs, urlparse
5
- from oauth2_cli_auth._timeout import _method_with_timeout, TimeoutException
6
-
7
-
8
- class CallbackPageTemplate:
9
- SUCCESS_SVG = """
10
- <svg xmlns="http://www.w3.org/2000/svg" width="154px" height="154px">
11
- <g fill="none" stroke="#22AE73" stroke-width="2">
12
- <circle cx="77" cy="77" r="72" style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
13
- <circle id="colored" fill="#22AE73" cx="77" cy="77" r="72" style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
14
- <polyline class="st0" stroke="#fff" stroke-width="10" points="43.5,77.8 63.7,97.9 112.2,49.4 " style="stroke-dasharray:100px, 100px; stroke-dashoffset: 200px;"/>
15
- </g>
16
- </svg>
17
- """
18
-
19
- ERROR_SVG = """
20
- <svg xmlns="http://www.w3.org/2000/svg" width="154px" height="154px">
21
- <g fill="none" stroke="#F44812" stroke-width="2">
22
- <circle cx="77" cy="77" r="72" style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
23
- <circle id="colored" fill="#F44812" cx="77" cy="77" r="72" style="stroke-dasharray:480px, 480px; stroke-dashoffset: 960px;"></circle>
24
- <polyline class="st0" stroke="#fff" stroke-width="10" points="43.5,77.8 112.2,77.8 " style="stroke-dasharray:100px, 100px; stroke-dashoffset: 200px;"/>
25
- </g>
26
- </svg>
27
- """
28
-
29
- PAGE_TEMPLATE = Template("""
30
- <html lang="$lang">
31
- <head>
32
- <title>$title</title>
33
- <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
34
- <meta name="charset" content="utf-8">
35
- <style>
36
- * {
37
- margin: 0;
38
- padding: 0;
39
- }
40
-
41
- body {
42
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
43
- }
44
-
45
- @media (prefers-color-scheme: dark) {
46
- body {
47
- background: rgb(34, 39, 46);
48
- color: rgb(173, 186, 199);
49
- }
50
- }
51
-
52
- html, body {
53
- display: flex;
54
- align-items: center;
55
- justify-content: center;
56
- height: 100%;
57
- }
58
-
59
- h1 {
60
- font-size: 4rem;
61
- }
62
-
63
- p {
64
- font-size: 1.4rem;
65
- max-width: 70ch;
66
- }
67
-
68
- .message {
69
- text-align: center;
70
- }
71
-
72
- .animation-ctn {
73
- text-align: center;
74
- }
75
-
76
- @keyframes checkmark {
77
- 0% {
78
- stroke-dashoffset: 100px
79
- }
80
-
81
- 100% {
82
- stroke-dashoffset: 0px
83
- }
84
- }
85
-
86
- @keyframes checkmark-circle {
87
- 0% {
88
- stroke-dashoffset: 480px
89
- }
90
-
91
- 100% {
92
- stroke-dashoffset: 960px
93
- }
94
- }
95
-
96
- @keyframes colored-circle {
97
- 0% {
98
- opacity: 0
99
- }
100
-
101
- 100% {
102
- opacity: 100
103
- }
104
- }
105
-
106
- .icon svg {
107
- padding: 1rem;
108
- }
109
-
110
- .icon svg polyline {
111
- -webkit-animation: checkmark 0.25s ease-in-out 0.7s backwards;
112
- animation: checkmark 0.25s ease-in-out 0.7s backwards
113
- }
114
-
115
- .icon svg circle {
116
- -webkit-animation: checkmark-circle 0.6s ease-in-out backwards;
117
- animation: checkmark-circle 0.6s ease-in-out backwards;
118
- }
119
-
120
- .icon svg circle#colored {
121
- -webkit-animation: colored-circle 0.6s ease-in-out 0.7s backwards;
122
- animation: colored-circle 0.6s ease-in-out 0.7s backwards;
123
- }
124
- </style>
125
- </head>
126
-
127
- <body>
128
- <div class="message">
129
- <div class="animation-ctn">
130
- <div class="icon">
131
- $svg
132
- </div>
133
- </div>
134
-
135
- <h1>$title</h1>
136
- <p>$message</p>
137
- </div>
138
- </body>
139
- </html>
140
- """)
141
-
142
- def render(self, title: str, message: str, lang: str = "en", has_error: bool = False):
143
- return self.PAGE_TEMPLATE.substitute(
144
- lang=lang,
145
- title=title,
146
- message=message,
147
- svg=self.ERROR_SVG if has_error else self.SUCCESS_SVG,
148
- )
149
-
150
-
151
- class OAuthRedirectHandler(BaseHTTPRequestHandler):
152
- callback_template = CallbackPageTemplate()
153
-
154
- def log_message(self, format, *args):
155
- # silence the log messages
156
- pass
157
-
158
- def do_GET(self):
159
- params = parse_qs(urlparse(self.path).query)
160
-
161
- has_error = "code" not in params or len(params['code']) != 1 or params['code'][0].strip() == ""
162
-
163
- if has_error:
164
- self.send_response(400)
165
- title = "Oh snap!"
166
- message = "Something went wrong trying to authenticate you. Please try going back in your browser, or restart the auth process."
167
- else:
168
- self.send_response(200)
169
- self.server._code = params["code"][0]
170
- title = "Success"
171
- message = "You have been authenticated successfully. You may close this browser window now and go back to the terminal"
172
-
173
- self.send_header("Content-type", "text/html")
174
- self.end_headers()
175
- self.wfile.write(
176
- self.callback_template
177
- .render(
178
- lang="en",
179
- title=title,
180
- message=message,
181
- has_error=has_error
182
- )
183
- .encode("utf-8")
184
- )
185
-
186
-
187
- class OAuthCallbackHttpServer(HTTPServer):
188
- """
189
- Simplistic HTTP Server to provide local callback URL for oauth2 provider
190
- """
191
-
192
- def __init__(self, port):
193
- super().__init__(("", port), OAuthRedirectHandler)
194
-
195
- self._code = None
196
-
197
- def get_code(self):
198
- return self._code
199
-
200
- @property
201
- def callback_url(self):
202
- return f"http://localhost:{self.server_port}"
203
-
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
- """
215
- for i in range(0, attempts):
216
- try:
217
- _method_with_timeout(self.handle_request, timeout_seconds=timeout_per_attempt)
218
- except TimeoutException:
219
- continue
220
- if self.get_code() is not None:
221
- return self.get_code()
222
-
223
- return None
File without changes