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.
Files changed (63) hide show
  1. rsconnect/__init__.py +13 -0
  2. rsconnect/actions.py +565 -0
  3. rsconnect/actions_content.py +508 -0
  4. rsconnect/actions_environment.py +160 -0
  5. rsconnect/actions_integration.py +118 -0
  6. rsconnect/api.py +2582 -0
  7. rsconnect/bundle.py +2481 -0
  8. rsconnect/certificates.py +39 -0
  9. rsconnect/environment.py +390 -0
  10. rsconnect/environment_node.py +115 -0
  11. rsconnect/environment_r.py +300 -0
  12. rsconnect/exception.py +15 -0
  13. rsconnect/git_metadata.py +180 -0
  14. rsconnect/http_support.py +595 -0
  15. rsconnect/json_web_token.py +178 -0
  16. rsconnect/log.py +253 -0
  17. rsconnect/main.py +5889 -0
  18. rsconnect/metadata.py +879 -0
  19. rsconnect/models.py +835 -0
  20. rsconnect/oauth.py +623 -0
  21. rsconnect/py.typed +0 -0
  22. rsconnect/pyproject.py +283 -0
  23. rsconnect/quickstart/__init__.py +16 -0
  24. rsconnect/quickstart/quickstart.py +486 -0
  25. rsconnect/quickstart/templates/__init__.py +16 -0
  26. rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
  27. rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
  28. rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
  29. rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
  30. rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
  31. rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
  32. rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
  33. rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
  34. rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
  35. rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
  36. rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
  37. rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
  38. rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
  39. rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
  40. rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
  41. rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
  42. rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
  43. rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
  44. rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
  45. rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
  46. rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
  47. rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
  48. rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
  49. rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
  50. rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
  51. rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
  52. rsconnect/shiny_express.py +136 -0
  53. rsconnect/snowflake.py +93 -0
  54. rsconnect/subprocesses/__init__.py +0 -0
  55. rsconnect/subprocesses/inspect_environment.py +362 -0
  56. rsconnect/timeouts.py +89 -0
  57. rsconnect/utils_package.py +261 -0
  58. rsconnect/validation.py +156 -0
  59. rsconnect/version_check.py +154 -0
  60. rsconnect_python-1.30.0.dist-info/METADATA +89 -0
  61. rsconnect_python-1.30.0.dist-info/RECORD +63 -0
  62. rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
  63. 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