rsconnect-python 1.30.0__py3-none-any.whl
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.
- rsconnect/__init__.py +13 -0
- rsconnect/actions.py +565 -0
- rsconnect/actions_content.py +508 -0
- rsconnect/actions_environment.py +160 -0
- rsconnect/actions_integration.py +118 -0
- rsconnect/api.py +2582 -0
- rsconnect/bundle.py +2481 -0
- rsconnect/certificates.py +39 -0
- rsconnect/environment.py +390 -0
- rsconnect/environment_node.py +115 -0
- rsconnect/environment_r.py +300 -0
- rsconnect/exception.py +15 -0
- rsconnect/git_metadata.py +180 -0
- rsconnect/http_support.py +595 -0
- rsconnect/json_web_token.py +178 -0
- rsconnect/log.py +253 -0
- rsconnect/main.py +5889 -0
- rsconnect/metadata.py +879 -0
- rsconnect/models.py +835 -0
- rsconnect/oauth.py +623 -0
- rsconnect/py.typed +0 -0
- rsconnect/pyproject.py +283 -0
- rsconnect/quickstart/__init__.py +16 -0
- rsconnect/quickstart/quickstart.py +486 -0
- rsconnect/quickstart/templates/__init__.py +16 -0
- rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
- rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
- rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
- rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
- rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
- rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
- rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
- rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
- rsconnect/shiny_express.py +136 -0
- rsconnect/snowflake.py +93 -0
- rsconnect/subprocesses/__init__.py +0 -0
- rsconnect/subprocesses/inspect_environment.py +362 -0
- rsconnect/timeouts.py +89 -0
- rsconnect/utils_package.py +261 -0
- rsconnect/validation.py +156 -0
- rsconnect/version_check.py +154 -0
- rsconnect_python-1.30.0.dist-info/METADATA +89 -0
- rsconnect_python-1.30.0.dist-info/RECORD +63 -0
- rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
- rsconnect_python-1.30.0.dist-info/entry_points.txt +3 -0
rsconnect/oauth.py
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""OAuth 2.1 authentication support for Posit Connect.
|
|
2
|
+
|
|
3
|
+
Implements RFC 8414 (discovery), RFC 7591 (DCR), Authorization Code + PKCE,
|
|
4
|
+
Device Code flow, token refresh, and keyring integration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import queue
|
|
12
|
+
import secrets
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import webbrowser
|
|
16
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer as _HTTPServer
|
|
17
|
+
from typing import Any, Dict, Optional, Tuple, cast
|
|
18
|
+
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
from .exception import RSConnectException
|
|
23
|
+
from .http_support import HTTPResponse, HTTPServer
|
|
24
|
+
from .log import logger
|
|
25
|
+
|
|
26
|
+
# pyright: reportMissingTypeStubs=false
|
|
27
|
+
|
|
28
|
+
_KEYRING_SERVICE = "rsconnect-python"
|
|
29
|
+
_CLIENT_NAME = "rsconnect-python"
|
|
30
|
+
_CALLBACK_TIMEOUT_SECONDS = 600
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InvalidClientError(RSConnectException):
|
|
34
|
+
"""Raised when the OAuth server returns an invalid_client error."""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
super().__init__("OAuth client_id is invalid or has been deleted on the server.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _check_oauth_error_response(response: HTTPResponse) -> None:
|
|
41
|
+
"""Check an HTTPResponse for OAuth error codes and raise appropriately."""
|
|
42
|
+
if response.json_data and isinstance(response.json_data, dict):
|
|
43
|
+
error = response.json_data.get("error", "")
|
|
44
|
+
if error == "invalid_client":
|
|
45
|
+
raise InvalidClientError()
|
|
46
|
+
description = response.json_data.get("error_description", error)
|
|
47
|
+
if description:
|
|
48
|
+
raise RSConnectException(f"OAuth error: {description}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _unwrap_json_response(response: Any) -> dict[str, Any]:
|
|
52
|
+
"""Extract JSON dict from an HTTPResponse (raw HTTPServer doesn't auto-unwrap).
|
|
53
|
+
|
|
54
|
+
Returns the dict if successful, raises RSConnectException on error responses.
|
|
55
|
+
"""
|
|
56
|
+
if isinstance(response, HTTPResponse):
|
|
57
|
+
if response.status and 200 <= response.status < 300 and isinstance(response.json_data, dict):
|
|
58
|
+
return cast(Dict[str, Any], response.json_data)
|
|
59
|
+
_check_oauth_error_response(response)
|
|
60
|
+
raise RSConnectException(f"OAuth request failed: HTTP {response.status}.")
|
|
61
|
+
if isinstance(response, dict):
|
|
62
|
+
return cast(Dict[str, Any], response)
|
|
63
|
+
raise RSConnectException("Unexpected OAuth response format.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def discover_oauth_metadata(
|
|
67
|
+
url: str,
|
|
68
|
+
insecure: bool = False,
|
|
69
|
+
ca_data: Optional[str | bytes] = None,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""Fetch OAuth 2.0 Authorization Server Metadata (RFC 8414).
|
|
72
|
+
|
|
73
|
+
Returns the parsed JSON metadata dict, or raises RSConnectException if
|
|
74
|
+
the server does not support OAuth.
|
|
75
|
+
"""
|
|
76
|
+
server = HTTPServer(url, disable_tls_check=insecure, ca_data=ca_data)
|
|
77
|
+
with server:
|
|
78
|
+
response = server.get("/.well-known/oauth-authorization-server")
|
|
79
|
+
|
|
80
|
+
if isinstance(response, HTTPResponse):
|
|
81
|
+
if response.status != 200:
|
|
82
|
+
raise RSConnectException(
|
|
83
|
+
f"Server at {url} does not support OAuth 2.1 "
|
|
84
|
+
f"(discovery endpoint returned HTTP {response.status}). "
|
|
85
|
+
f"The server may need to be upgraded, or OAuth may be intentionally disabled by an administrator."
|
|
86
|
+
)
|
|
87
|
+
if isinstance(response.json_data, dict) and "token_endpoint" in response.json_data:
|
|
88
|
+
return response.json_data
|
|
89
|
+
raise RSConnectException(f"Server at {url} returned a non-JSON response from the OAuth discovery endpoint.")
|
|
90
|
+
|
|
91
|
+
if not isinstance(response, dict) or "token_endpoint" not in response:
|
|
92
|
+
raise RSConnectException(f"Server at {url} returned invalid OAuth metadata (missing token_endpoint).")
|
|
93
|
+
|
|
94
|
+
return response
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def register_client(
|
|
98
|
+
metadata: dict[str, Any],
|
|
99
|
+
url: str,
|
|
100
|
+
insecure: bool = False,
|
|
101
|
+
ca_data: Optional[str | bytes] = None,
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Register an OAuth client via Dynamic Client Registration (RFC 7591).
|
|
104
|
+
|
|
105
|
+
Returns the client_id.
|
|
106
|
+
"""
|
|
107
|
+
registration_endpoint = str(metadata.get("registration_endpoint", ""))
|
|
108
|
+
if not registration_endpoint:
|
|
109
|
+
raise RSConnectException("OAuth metadata does not include a registration_endpoint.")
|
|
110
|
+
|
|
111
|
+
parsed = urlparse(registration_endpoint)
|
|
112
|
+
base = f"{parsed.scheme}://{parsed.netloc}"
|
|
113
|
+
path = parsed.path
|
|
114
|
+
|
|
115
|
+
grant_types = ["authorization_code", "refresh_token"]
|
|
116
|
+
if metadata.get("device_authorization_endpoint"):
|
|
117
|
+
grant_types.append("urn:ietf:params:oauth:grant-type:device_code")
|
|
118
|
+
|
|
119
|
+
server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data)
|
|
120
|
+
with server:
|
|
121
|
+
response = server.post(
|
|
122
|
+
path,
|
|
123
|
+
body={
|
|
124
|
+
"client_name": _CLIENT_NAME,
|
|
125
|
+
"redirect_uris": ["http://127.0.0.1/callback"],
|
|
126
|
+
"token_endpoint_auth_method": "none",
|
|
127
|
+
"grant_types": grant_types,
|
|
128
|
+
"response_types": ["code"],
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
data = _unwrap_json_response(response)
|
|
133
|
+
if "client_id" not in data:
|
|
134
|
+
raise RSConnectException("OAuth client registration returned an unexpected response (no client_id).")
|
|
135
|
+
|
|
136
|
+
return str(data["client_id"])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def generate_pkce_pair() -> Tuple[str, str]:
|
|
140
|
+
"""Generate a PKCE code_verifier and code_challenge (S256)."""
|
|
141
|
+
code_verifier = secrets.token_urlsafe(96)[:96]
|
|
142
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
143
|
+
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
144
|
+
return code_verifier, code_challenge
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _exchange_code_for_token(
|
|
148
|
+
metadata: dict[str, Any],
|
|
149
|
+
client_id: str,
|
|
150
|
+
code: str,
|
|
151
|
+
code_verifier: str,
|
|
152
|
+
redirect_uri: str,
|
|
153
|
+
insecure: bool = False,
|
|
154
|
+
ca_data: Optional[str | bytes] = None,
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""Exchange an authorization code for tokens."""
|
|
157
|
+
token_endpoint = str(metadata["token_endpoint"])
|
|
158
|
+
parsed = urlparse(token_endpoint)
|
|
159
|
+
base = f"{parsed.scheme}://{parsed.netloc}"
|
|
160
|
+
path = parsed.path
|
|
161
|
+
|
|
162
|
+
body = urlencode(
|
|
163
|
+
{
|
|
164
|
+
"grant_type": "authorization_code",
|
|
165
|
+
"client_id": client_id,
|
|
166
|
+
"code": code,
|
|
167
|
+
"redirect_uri": redirect_uri,
|
|
168
|
+
"code_verifier": code_verifier,
|
|
169
|
+
}
|
|
170
|
+
).encode("utf-8")
|
|
171
|
+
|
|
172
|
+
server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data)
|
|
173
|
+
with server:
|
|
174
|
+
response = server.request(
|
|
175
|
+
"POST",
|
|
176
|
+
path,
|
|
177
|
+
body=body,
|
|
178
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
data = _unwrap_json_response(response)
|
|
182
|
+
if "access_token" not in data:
|
|
183
|
+
raise RSConnectException("Token exchange returned an unexpected response.")
|
|
184
|
+
|
|
185
|
+
return data
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
189
|
+
"""HTTP request handler for the OAuth redirect callback."""
|
|
190
|
+
|
|
191
|
+
result_queue: queue.Queue[Tuple[str, Optional[str], Optional[str]]]
|
|
192
|
+
|
|
193
|
+
def do_GET(self) -> None: # noqa: N802
|
|
194
|
+
qs = parse_qs(urlparse(self.path).query)
|
|
195
|
+
code = qs.get("code", [None])[0]
|
|
196
|
+
state = qs.get("state", [None])[0]
|
|
197
|
+
error = qs.get("error", [None])[0]
|
|
198
|
+
|
|
199
|
+
self.send_response(200)
|
|
200
|
+
self.send_header("Content-Type", "text/html")
|
|
201
|
+
self.end_headers()
|
|
202
|
+
|
|
203
|
+
if error:
|
|
204
|
+
self.wfile.write(b"<html><body><h1>Authentication failed.</h1><p>You may close this tab.</p></body></html>")
|
|
205
|
+
self.result_queue.put(("error", error, qs.get("error_description", [""])[0]))
|
|
206
|
+
elif code:
|
|
207
|
+
self.wfile.write(
|
|
208
|
+
b"<html><body><h1>Authentication successful!</h1><p>You may close this tab.</p></body></html>"
|
|
209
|
+
)
|
|
210
|
+
self.result_queue.put(("success", code, state))
|
|
211
|
+
else:
|
|
212
|
+
self.wfile.write(b"<html><body><h1>Unexpected response.</h1></body></html>")
|
|
213
|
+
self.result_queue.put(("error", "no_code", "No authorization code in callback"))
|
|
214
|
+
|
|
215
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
216
|
+
logger.debug(f"OAuth callback server: {format % args}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def login_with_browser(
|
|
220
|
+
url: str,
|
|
221
|
+
client_id: str,
|
|
222
|
+
metadata: dict[str, Any],
|
|
223
|
+
insecure: bool = False,
|
|
224
|
+
ca_data: Optional[str | bytes] = None,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
"""Perform OAuth Authorization Code + PKCE flow via browser.
|
|
227
|
+
|
|
228
|
+
Opens the user's browser to the authorization URL and starts a local
|
|
229
|
+
HTTP server to receive the callback. Returns the token response dict.
|
|
230
|
+
"""
|
|
231
|
+
code_verifier, code_challenge = generate_pkce_pair()
|
|
232
|
+
state = secrets.token_urlsafe(32)
|
|
233
|
+
|
|
234
|
+
result_queue: queue.Queue[Tuple[str, Optional[str], Optional[str]]] = queue.Queue()
|
|
235
|
+
|
|
236
|
+
callback_server = _HTTPServer(("127.0.0.1", 0), _CallbackHandler)
|
|
237
|
+
port = callback_server.server_address[1]
|
|
238
|
+
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
239
|
+
|
|
240
|
+
# Attach queue to the handler class for this server instance
|
|
241
|
+
callback_server.RequestHandlerClass.result_queue = result_queue # type: ignore[attr-defined]
|
|
242
|
+
|
|
243
|
+
auth_endpoint = str(metadata["authorization_endpoint"])
|
|
244
|
+
auth_params = urlencode(
|
|
245
|
+
{
|
|
246
|
+
"response_type": "code",
|
|
247
|
+
"client_id": client_id,
|
|
248
|
+
"redirect_uri": redirect_uri,
|
|
249
|
+
"code_challenge": code_challenge,
|
|
250
|
+
"code_challenge_method": "S256",
|
|
251
|
+
"state": state,
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
auth_url = f"{auth_endpoint}?{auth_params}"
|
|
255
|
+
|
|
256
|
+
server_thread = threading.Thread(target=callback_server.handle_request, daemon=True)
|
|
257
|
+
server_thread.start()
|
|
258
|
+
|
|
259
|
+
if not webbrowser.open(auth_url):
|
|
260
|
+
click.echo(
|
|
261
|
+
f"Could not open browser automatically. This can happen if no display is available\n"
|
|
262
|
+
f"or localhost is blocked by network rules. Please open this URL manually:\n\n"
|
|
263
|
+
f" {auth_url}\n\n"
|
|
264
|
+
f"Waiting for authentication callback..."
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
click.echo("Opened browser for authentication. Waiting for callback...")
|
|
268
|
+
|
|
269
|
+
server_thread.join(timeout=_CALLBACK_TIMEOUT_SECONDS)
|
|
270
|
+
callback_server.server_close()
|
|
271
|
+
|
|
272
|
+
if result_queue.empty():
|
|
273
|
+
raise RSConnectException(f"OAuth browser callback timed out after {_CALLBACK_TIMEOUT_SECONDS} seconds.")
|
|
274
|
+
|
|
275
|
+
result = result_queue.get_nowait()
|
|
276
|
+
if result[0] == "error":
|
|
277
|
+
raise RSConnectException(f"OAuth authentication failed: {result[1]} — {result[2]}")
|
|
278
|
+
|
|
279
|
+
_, code, returned_state = result
|
|
280
|
+
if returned_state != state:
|
|
281
|
+
raise RSConnectException("OAuth state mismatch — possible CSRF attack.")
|
|
282
|
+
if not code:
|
|
283
|
+
raise RSConnectException("OAuth callback did not contain an authorization code.")
|
|
284
|
+
|
|
285
|
+
return _exchange_code_for_token(metadata, client_id, code, code_verifier, redirect_uri, insecure, ca_data)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def login_with_device_code(
|
|
289
|
+
url: str,
|
|
290
|
+
client_id: str,
|
|
291
|
+
metadata: dict[str, Any],
|
|
292
|
+
insecure: bool = False,
|
|
293
|
+
ca_data: Optional[str | bytes] = None,
|
|
294
|
+
) -> dict[str, Any]:
|
|
295
|
+
"""Perform OAuth Device Code flow.
|
|
296
|
+
|
|
297
|
+
Displays a URL and user code for the user to enter in a browser,
|
|
298
|
+
then polls for token completion.
|
|
299
|
+
"""
|
|
300
|
+
device_endpoint = str(metadata.get("device_authorization_endpoint", ""))
|
|
301
|
+
if not device_endpoint:
|
|
302
|
+
raise RSConnectException(
|
|
303
|
+
"Server does not support the device code flow. "
|
|
304
|
+
"The server may need to be upgraded, or the device code flow may be "
|
|
305
|
+
"intentionally disabled by an administrator. Try again without --use-device-code."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
parsed = urlparse(device_endpoint)
|
|
309
|
+
base = f"{parsed.scheme}://{parsed.netloc}"
|
|
310
|
+
path = parsed.path
|
|
311
|
+
|
|
312
|
+
body = urlencode({"client_id": client_id}).encode("utf-8")
|
|
313
|
+
|
|
314
|
+
server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data)
|
|
315
|
+
with server:
|
|
316
|
+
response = server.request(
|
|
317
|
+
"POST",
|
|
318
|
+
path,
|
|
319
|
+
body=body,
|
|
320
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
resp = _unwrap_json_response(response)
|
|
324
|
+
device_code = str(resp.get("device_code", ""))
|
|
325
|
+
user_code = str(resp.get("user_code", ""))
|
|
326
|
+
verification_uri = str(resp.get("verification_uri", ""))
|
|
327
|
+
interval = int(resp.get("interval", 5))
|
|
328
|
+
expires_in = int(resp.get("expires_in", 600))
|
|
329
|
+
|
|
330
|
+
verification_uri_complete = str(resp.get("verification_uri_complete", "")) or verification_uri
|
|
331
|
+
|
|
332
|
+
click.echo(f"\nOpen this URL in your browser:\n\n {verification_uri_complete}\n")
|
|
333
|
+
click.echo(f"Enter the code: {user_code}\n")
|
|
334
|
+
click.echo("Waiting for authorization...")
|
|
335
|
+
|
|
336
|
+
return _poll_for_device_token(metadata, client_id, device_code, interval, expires_in, insecure, ca_data)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _poll_for_device_token(
|
|
340
|
+
metadata: dict[str, Any],
|
|
341
|
+
client_id: str,
|
|
342
|
+
device_code: str,
|
|
343
|
+
interval: int,
|
|
344
|
+
expires_in: int,
|
|
345
|
+
insecure: bool = False,
|
|
346
|
+
ca_data: Optional[str | bytes] = None,
|
|
347
|
+
) -> dict[str, Any]:
|
|
348
|
+
"""Poll the token endpoint for device code completion."""
|
|
349
|
+
token_endpoint = str(metadata["token_endpoint"])
|
|
350
|
+
parsed = urlparse(token_endpoint)
|
|
351
|
+
base = f"{parsed.scheme}://{parsed.netloc}"
|
|
352
|
+
path = parsed.path
|
|
353
|
+
|
|
354
|
+
deadline = time.time() + expires_in
|
|
355
|
+
poll_interval = interval
|
|
356
|
+
|
|
357
|
+
while time.time() < deadline:
|
|
358
|
+
time.sleep(poll_interval)
|
|
359
|
+
|
|
360
|
+
body = urlencode(
|
|
361
|
+
{
|
|
362
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
363
|
+
"client_id": client_id,
|
|
364
|
+
"device_code": device_code,
|
|
365
|
+
}
|
|
366
|
+
).encode("utf-8")
|
|
367
|
+
|
|
368
|
+
server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data)
|
|
369
|
+
with server:
|
|
370
|
+
response = server.request(
|
|
371
|
+
"POST",
|
|
372
|
+
path,
|
|
373
|
+
body=body,
|
|
374
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Extract JSON from the response (raw HTTPServer always returns HTTPResponse)
|
|
378
|
+
json_data: Optional[dict[str, Any]] = None
|
|
379
|
+
if isinstance(response, HTTPResponse):
|
|
380
|
+
if isinstance(response.json_data, dict):
|
|
381
|
+
json_data = response.json_data
|
|
382
|
+
else:
|
|
383
|
+
raise RSConnectException(f"Device code token request failed: HTTP {response.status}.")
|
|
384
|
+
elif isinstance(response, dict):
|
|
385
|
+
json_data = response
|
|
386
|
+
|
|
387
|
+
if json_data is None:
|
|
388
|
+
raise RSConnectException("Device code token request returned an unexpected response.")
|
|
389
|
+
|
|
390
|
+
if "access_token" in json_data:
|
|
391
|
+
return json_data
|
|
392
|
+
|
|
393
|
+
error = str(json_data.get("error", ""))
|
|
394
|
+
if error == "authorization_pending":
|
|
395
|
+
continue
|
|
396
|
+
elif error == "slow_down":
|
|
397
|
+
poll_interval += 5
|
|
398
|
+
continue
|
|
399
|
+
elif error == "invalid_client":
|
|
400
|
+
raise InvalidClientError()
|
|
401
|
+
elif error == "expired_token":
|
|
402
|
+
raise RSConnectException("Device code expired. Please try again.")
|
|
403
|
+
elif error == "access_denied":
|
|
404
|
+
raise RSConnectException("Authorization was denied by the user.")
|
|
405
|
+
elif error:
|
|
406
|
+
description = str(json_data.get("error_description", error))
|
|
407
|
+
raise RSConnectException(f"Device code flow failed: {description}")
|
|
408
|
+
else:
|
|
409
|
+
raise RSConnectException("Device code token request returned an unexpected response.")
|
|
410
|
+
|
|
411
|
+
raise RSConnectException("Device code authorization timed out. Please try again.")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def refresh_access_token(
|
|
415
|
+
metadata: dict[str, Any],
|
|
416
|
+
client_id: str,
|
|
417
|
+
refresh_token: str,
|
|
418
|
+
insecure: bool = False,
|
|
419
|
+
ca_data: Optional[str | bytes] = None,
|
|
420
|
+
) -> dict[str, Any]:
|
|
421
|
+
"""Refresh an OAuth access token using a refresh token.
|
|
422
|
+
|
|
423
|
+
Returns the new token response dict. Raises InvalidClientError if the
|
|
424
|
+
client_id has been deleted server-side.
|
|
425
|
+
"""
|
|
426
|
+
token_endpoint = str(metadata["token_endpoint"])
|
|
427
|
+
parsed = urlparse(token_endpoint)
|
|
428
|
+
base = f"{parsed.scheme}://{parsed.netloc}"
|
|
429
|
+
path = parsed.path
|
|
430
|
+
|
|
431
|
+
body = urlencode(
|
|
432
|
+
{
|
|
433
|
+
"grant_type": "refresh_token",
|
|
434
|
+
"client_id": client_id,
|
|
435
|
+
"refresh_token": refresh_token,
|
|
436
|
+
}
|
|
437
|
+
).encode("utf-8")
|
|
438
|
+
|
|
439
|
+
server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data)
|
|
440
|
+
with server:
|
|
441
|
+
response = server.request(
|
|
442
|
+
"POST",
|
|
443
|
+
path,
|
|
444
|
+
body=body,
|
|
445
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
data = _unwrap_json_response(response)
|
|
449
|
+
if "access_token" not in data:
|
|
450
|
+
raise RSConnectException("Token refresh returned an unexpected response.")
|
|
451
|
+
|
|
452
|
+
return data
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
_TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
456
|
+
_ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
|
|
457
|
+
_ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def exchange_token_for_api_key(
|
|
461
|
+
url: str,
|
|
462
|
+
subject_token: str,
|
|
463
|
+
insecure: bool = False,
|
|
464
|
+
ca_data: Optional[str | bytes] = None,
|
|
465
|
+
) -> str:
|
|
466
|
+
"""Exchange an OIDC identity token for a short-lived Connect API key (RFC 8693).
|
|
467
|
+
|
|
468
|
+
This performs an OAuth token exchange against Connect's token endpoint.
|
|
469
|
+
Connect verifies the OIDC ``subject_token`` and, if it matches a service
|
|
470
|
+
principal that has been granted access (e.g. via trusted publishing or
|
|
471
|
+
identity federation), mints an ephemeral API key.
|
|
472
|
+
|
|
473
|
+
The server's OAuth metadata is discovered first; this both confirms that the
|
|
474
|
+
server supports token exchange (rather than discovering that via a failed
|
|
475
|
+
request) and yields the correct token endpoint, honoring any path prefix.
|
|
476
|
+
|
|
477
|
+
Returns the API key. Raises RSConnectException with an actionable message
|
|
478
|
+
when the exchange is unsupported or fails.
|
|
479
|
+
"""
|
|
480
|
+
metadata = discover_oauth_metadata(url, insecure, ca_data)
|
|
481
|
+
|
|
482
|
+
grant_types = metadata.get("grant_types_supported")
|
|
483
|
+
if isinstance(grant_types, list) and _TOKEN_EXCHANGE_GRANT not in grant_types:
|
|
484
|
+
raise RSConnectException(
|
|
485
|
+
f"The server at {url} does not support OIDC token exchange. "
|
|
486
|
+
"It may need to be upgraded, or you can authenticate with an API key instead."
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
token_endpoint = str(metadata["token_endpoint"])
|
|
490
|
+
parsed = urlparse(token_endpoint)
|
|
491
|
+
base = f"{parsed.scheme}://{parsed.netloc}"
|
|
492
|
+
# Preserve the full request target (path, params, and query) from the
|
|
493
|
+
# discovered endpoint, not just the path.
|
|
494
|
+
request_target = urlunparse(("", "", parsed.path, parsed.params, parsed.query, ""))
|
|
495
|
+
|
|
496
|
+
body = urlencode(
|
|
497
|
+
{
|
|
498
|
+
"grant_type": _TOKEN_EXCHANGE_GRANT,
|
|
499
|
+
"subject_token_type": _ID_TOKEN_TYPE,
|
|
500
|
+
"requested_token_type": _ACCESS_TOKEN_TYPE,
|
|
501
|
+
"subject_token": subject_token,
|
|
502
|
+
}
|
|
503
|
+
).encode("utf-8")
|
|
504
|
+
|
|
505
|
+
server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data)
|
|
506
|
+
with server:
|
|
507
|
+
response = server.request(
|
|
508
|
+
"POST",
|
|
509
|
+
request_target,
|
|
510
|
+
body=body,
|
|
511
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
if not isinstance(response, HTTPResponse):
|
|
515
|
+
raise RSConnectException("Unexpected response from the OIDC token exchange.")
|
|
516
|
+
|
|
517
|
+
if response.exception:
|
|
518
|
+
raise RSConnectException("Could not connect to %s - %s" % (url, response.exception), cause=response.exception)
|
|
519
|
+
|
|
520
|
+
status = response.status
|
|
521
|
+
data = response.json_data if isinstance(response.json_data, dict) else {}
|
|
522
|
+
|
|
523
|
+
if status and 200 <= status < 300:
|
|
524
|
+
api_key = data.get("access_token")
|
|
525
|
+
if not api_key:
|
|
526
|
+
raise RSConnectException("Connect returned a successful token exchange but no API key (access_token).")
|
|
527
|
+
return str(api_key)
|
|
528
|
+
|
|
529
|
+
raise _token_exchange_error(status, data)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _token_exchange_error(status: Optional[int], data: dict[str, Any]) -> RSConnectException:
|
|
533
|
+
"""Translate a failed token-exchange response into an actionable exception."""
|
|
534
|
+
error = str(data.get("error", "")) if data else ""
|
|
535
|
+
description = str(data.get("error_description", "")) if data else ""
|
|
536
|
+
|
|
537
|
+
if status == 400 and error == "invalid_grant":
|
|
538
|
+
lowered = description.lower()
|
|
539
|
+
if "ambiguous" in lowered:
|
|
540
|
+
return RSConnectException(
|
|
541
|
+
f"The identity token matched more than one service principal on Connect ({description}). "
|
|
542
|
+
"Resolve the duplicate access grants on the server, or authenticate with an API key."
|
|
543
|
+
)
|
|
544
|
+
if "verif" in lowered:
|
|
545
|
+
return RSConnectException(
|
|
546
|
+
f"Connect could not verify the identity token ({description}). "
|
|
547
|
+
"Check the server clock and the OIDC issuer configuration, or authenticate with an API key."
|
|
548
|
+
)
|
|
549
|
+
return RSConnectException(
|
|
550
|
+
f"Connect did not grant access for this identity token ({description or 'no match'}). "
|
|
551
|
+
"Confirm access has been configured for the target content and that the token's "
|
|
552
|
+
"audience matches it, or authenticate with an API key."
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
detail = error
|
|
556
|
+
if description:
|
|
557
|
+
detail = f"{error}: {description}" if error else description
|
|
558
|
+
suffix = f" ({detail})" if detail else ""
|
|
559
|
+
return RSConnectException(f"OIDC token exchange failed (HTTP {status}){suffix}.")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# ---------------------------------------------------------------------------
|
|
563
|
+
# Keyring integration
|
|
564
|
+
# ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def keyring_store_token(server_url: str, access_token: str, refresh_token: Optional[str]) -> bool:
|
|
568
|
+
"""Store OAuth tokens in the system keyring.
|
|
569
|
+
|
|
570
|
+
Returns True on success, False if keyring is not available.
|
|
571
|
+
"""
|
|
572
|
+
try:
|
|
573
|
+
import keyring # type: ignore[import-untyped]
|
|
574
|
+
|
|
575
|
+
keyring.set_password(_KEYRING_SERVICE, f"{server_url}:access_token", access_token)
|
|
576
|
+
if refresh_token:
|
|
577
|
+
keyring.set_password(_KEYRING_SERVICE, f"{server_url}:refresh_token", refresh_token)
|
|
578
|
+
else:
|
|
579
|
+
try:
|
|
580
|
+
keyring.delete_password(_KEYRING_SERVICE, f"{server_url}:refresh_token")
|
|
581
|
+
except keyring.errors.PasswordDeleteError:
|
|
582
|
+
pass
|
|
583
|
+
return True
|
|
584
|
+
except ImportError:
|
|
585
|
+
return False
|
|
586
|
+
except Exception as e:
|
|
587
|
+
logger.warning(f"keyring storage failed: {e}")
|
|
588
|
+
return False
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def keyring_get_tokens(server_url: str) -> Tuple[Optional[str], Optional[str]]:
|
|
592
|
+
"""Retrieve OAuth tokens from the system keyring.
|
|
593
|
+
|
|
594
|
+
Returns (access_token, refresh_token), or (None, None) if unavailable.
|
|
595
|
+
"""
|
|
596
|
+
try:
|
|
597
|
+
import keyring # type: ignore[import-untyped]
|
|
598
|
+
|
|
599
|
+
access = keyring.get_password(_KEYRING_SERVICE, f"{server_url}:access_token")
|
|
600
|
+
refresh = keyring.get_password(_KEYRING_SERVICE, f"{server_url}:refresh_token")
|
|
601
|
+
return access, refresh
|
|
602
|
+
except ImportError:
|
|
603
|
+
return None, None
|
|
604
|
+
except Exception as e:
|
|
605
|
+
logger.warning(f"keyring retrieval failed: {e}")
|
|
606
|
+
return None, None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def keyring_delete_tokens(server_url: str) -> None:
|
|
610
|
+
"""Delete OAuth tokens from the system keyring."""
|
|
611
|
+
try:
|
|
612
|
+
import keyring # type: ignore[import-untyped]
|
|
613
|
+
import keyring.errors # type: ignore[import-untyped]
|
|
614
|
+
|
|
615
|
+
for suffix in (":access_token", ":refresh_token"):
|
|
616
|
+
try:
|
|
617
|
+
keyring.delete_password(_KEYRING_SERVICE, f"{server_url}{suffix}")
|
|
618
|
+
except keyring.errors.PasswordDeleteError:
|
|
619
|
+
pass
|
|
620
|
+
except ImportError:
|
|
621
|
+
pass
|
|
622
|
+
except Exception as e:
|
|
623
|
+
logger.warning(f"keyring deletion failed: {e}")
|
rsconnect/py.typed
ADDED
|
File without changes
|