ado-keyring 0.1.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.
@@ -0,0 +1,18 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.9", "3.12"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v5
18
+ - run: uv run --python ${{ matrix.python-version }} pytest tests/ -v
@@ -0,0 +1,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions: {}
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 0
17
+ - uses: astral-sh/setup-uv@v5
18
+ - run: uv build
19
+ - uses: actions/upload-artifact@v4
20
+ with:
21
+ name: dist
22
+ path: dist/
23
+
24
+ publish:
25
+ needs: build
26
+ runs-on: ubuntu-latest
27
+ environment: pypi
28
+ permissions:
29
+ id-token: write
30
+ steps:
31
+ - uses: actions/download-artifact@v4
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ /target
2
+ __pycache__
3
+ *.egg-info
4
+ *.so
5
+ *.whl
6
+ .venv
7
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cameron Taggart
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: ado-keyring
3
+ Version: 0.1.0
4
+ License-Expression: MIT
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: keyring>=23.0
8
+ Requires-Dist: requests>=2.28
@@ -0,0 +1,50 @@
1
+ # ado-keyring
2
+
3
+ A [keyring](https://pypi.org/project/keyring/) backend in pure python that authenticates to Azure DevOps package feeds using browser-based OAuth2 with PKCE.
4
+
5
+ Once installed, keyring automatically discovers `ado-keyring` as a backend. Any tool that uses keyring, such as [`uv`](https://docs.astral.sh/uv/) for [alternative indexes](https://docs.astral.sh/uv/guides/integration/alternative-indexes/), will trigger browser auth when accessing Azure DevOps feeds.
6
+
7
+ ## Install from PyPi
8
+
9
+ ```sh
10
+ uv tool install keyring --with ado-keyring
11
+ ```
12
+
13
+ ## Features
14
+
15
+ - **Browser-based OAuth2 + PKCE** — secure, no secrets stored in config files
16
+ - **Persistent token cache** — avoids repeated browser prompts (`~/.ado-keyring/`)
17
+ - **Automatic token refresh** — uses refresh tokens to silently renew access
18
+ - **Per-org session tokens** — supports multiple Azure DevOps organizations
19
+ - **WSL-aware** — opens the Windows browser from WSL via `cmd.exe`
20
+ - **No .NET dependency** — pure Python, unlike [`artifacts-keyring`](https://github.com/microsoft/artifacts-keyring)
21
+
22
+ ## Install from source
23
+
24
+ ```sh
25
+ just install
26
+ ```
27
+
28
+ ## Install on WSL from source
29
+
30
+ ```sh
31
+ tdnf install -y python3 python3-pip
32
+ python3 -m pip install
33
+ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc
34
+ uv tool install rust-just
35
+ just install
36
+ ```
37
+
38
+ ## How It Works
39
+
40
+ 1. Binds a localhost callback server on a random port
41
+ 2. Opens the browser to Azure AD's OAuth2 authorize endpoint (PKCE, `select_account` prompt)
42
+ 3. Receives the authorization code via redirect
43
+ 4. Exchanges the code for access + refresh tokens
44
+ 5. Exchanges the access token for a `VssSessionToken` scoped to `vso.packaging`
45
+ 6. Caches all tokens to `~/.ado-keyring/token-cache.json` (file: `0600`, dir: `0700`)
46
+ 7. On subsequent calls, uses cached session tokens or silently refreshes via the refresh token
47
+
48
+ ## License
49
+
50
+ [MIT](LICENSE)
@@ -0,0 +1,28 @@
1
+ set windows-shell := ["cmd.exe", "/c"]
2
+
3
+ # Show the current version
4
+ version:
5
+ uvx --with hatch-vcs hatchling version
6
+
7
+ # Build the wheel
8
+ build:
9
+ uv build
10
+
11
+ # Install into the system keyring tool environment
12
+ install: build
13
+ uv tool install --force keyring --with dist/ado_keyring-*.whl
14
+
15
+ # Verify the backend is registered
16
+ check:
17
+ keyring --list-backends
18
+
19
+ # Build, install, and verify
20
+ all: install check
21
+
22
+ # Run tests
23
+ test:
24
+ uv run pytest tests/ -v
25
+
26
+ # Clean build artifacts
27
+ clean:
28
+ rm -rf dist/ build/ *.egg-info
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ado-keyring"
7
+ dynamic = ["version"]
8
+ license = "MIT"
9
+ requires-python = ">=3.9"
10
+ dependencies = ["keyring>=23.0", "requests>=2.28"]
11
+
12
+ [project.entry-points."keyring.backends"]
13
+ ado-keyring = "ado_keyring:AdoKeyring"
14
+
15
+ [dependency-groups]
16
+ dev = ["pytest>=7.0"]
17
+
18
+ [tool.hatch.version]
19
+ source = "vcs"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["python/ado_keyring"]
@@ -0,0 +1,360 @@
1
+ """Keyring backend for Azure DevOps feeds using browser-based OAuth2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import platform
10
+ import secrets
11
+ import socket
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Optional, Tuple
17
+ from urllib.parse import parse_qs, urlencode, urlparse
18
+
19
+ import keyring.backend
20
+ import keyring.credentials
21
+ import requests
22
+
23
+ # Azure CLI public client
24
+ _CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
25
+ # Azure DevOps resource ID in Microsoft Entra ID, requesting default permissions and a refresh token
26
+ _SCOPE = "499b84ac-1321-427f-aa17-267ca6975798/.default offline_access"
27
+ _AUTH_URL = "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize"
28
+ _TOKEN_URL = "https://login.microsoftonline.com/organizations/oauth2/v2.0/token"
29
+
30
+ _LOG_PREFIX = "[ado-keyring]"
31
+ _HTTP_TIMEOUT = 30 # seconds for all HTTP requests
32
+ _CALLBACK_TIMEOUT = 120 # seconds to wait for browser callback
33
+
34
+
35
+ # ── Token cache ──────────────────────────────────────────────────────────────
36
+
37
+ def _cache_path() -> Path:
38
+ return Path.home() / ".ado-keyring" / "token-cache.json"
39
+
40
+
41
+ def _load_cache() -> Optional[Dict[str, Any]]:
42
+ try:
43
+ return json.loads(_cache_path().read_text())
44
+ except (OSError, json.JSONDecodeError):
45
+ return None
46
+
47
+
48
+ def _save_cache(cache: Dict[str, Any]) -> None:
49
+ path = _cache_path()
50
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
51
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
52
+ with os.fdopen(fd, "w") as f:
53
+ json.dump(cache, f, indent=2)
54
+
55
+
56
+ # ── URL helpers ──────────────────────────────────────────────────────────────
57
+
58
+ _DEVOPS_HOSTS = (
59
+ "visualstudio.com",
60
+ "dev.azure.com",
61
+ "pkgs.codedev.ms",
62
+ "pkgs.vsts.me",
63
+ )
64
+
65
+
66
+ def _is_devops_url(url: str) -> bool:
67
+ return any(h in url for h in _DEVOPS_HOSTS)
68
+
69
+
70
+ def _extract_org(service_url: str) -> Optional[str]:
71
+ parsed = urlparse(service_url)
72
+ host = parsed.hostname or ""
73
+ if host.endswith("visualstudio.com") or host.endswith("vsts.me") or host.endswith("codedev.ms"):
74
+ return host.split(".")[0]
75
+ elif "dev.azure.com" in host:
76
+ parts = parsed.path.strip("/").split("/")
77
+ return parts[0] if parts and parts[0] else None
78
+ return None
79
+
80
+
81
+ # ── PKCE ─────────────────────────────────────────────────────────────────────
82
+
83
+ def _generate_pkce() -> Tuple[str, str]:
84
+ verifier_bytes = secrets.token_bytes(32)
85
+ verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b"=").decode()
86
+ challenge_hash = hashlib.sha256(verifier.encode()).digest()
87
+ challenge = base64.urlsafe_b64encode(challenge_hash).rstrip(b"=").decode()
88
+ return verifier, challenge
89
+
90
+
91
+ # ── WSL-aware browser opening ────────────────────────────────────────────────
92
+
93
+ def _is_wsl() -> bool:
94
+ try:
95
+ return "microsoft" in Path("/proc/version").read_text().lower()
96
+ except OSError:
97
+ return False
98
+
99
+
100
+ def _open_browser(url: str) -> None:
101
+ devnull = subprocess.DEVNULL
102
+ attempts: list[Tuple[str, list[str]]] = []
103
+
104
+ if _is_wsl():
105
+ attempts = [
106
+ (
107
+ "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
108
+ ["-NoProfile", "-Command", f"Start-Process '{url}'"],
109
+ ),
110
+ (
111
+ "/mnt/c/Windows/system32/cmd.exe",
112
+ ["/c", "start", "", url],
113
+ ),
114
+ ]
115
+ elif platform.system() == "Darwin":
116
+ attempts = [("open", [url])]
117
+ else:
118
+ attempts = [("xdg-open", [url])]
119
+
120
+ for cmd, args in attempts:
121
+ try:
122
+ result = subprocess.run(
123
+ [cmd, *args], stdin=devnull, stdout=devnull, stderr=devnull
124
+ )
125
+ if result.returncode == 0:
126
+ return
127
+ except FileNotFoundError:
128
+ continue
129
+
130
+ raise RuntimeError(
131
+ f"Could not open browser.\nPlease open this URL manually:\n{url}"
132
+ )
133
+
134
+
135
+ # ── OAuth2 browser auth flow ────────────────────────────────────────────────
136
+
137
+ _SUCCESS_HTML = (
138
+ "<html><head><title>Authentication Successful</title></head>"
139
+ '<body style="font-family:sans-serif;text-align:center;margin-top:80px">'
140
+ "<h1>Authentication Successful</h1>"
141
+ "<p>You can close this tab and return to the terminal.</p>"
142
+ "</body></html>"
143
+ )
144
+
145
+ _ERROR_HTML = (
146
+ "<html><head><title>Authentication Failed</title></head>"
147
+ '<body style="font-family:sans-serif;text-align:center;margin-top:80px">'
148
+ "<h1>Authentication Failed</h1>"
149
+ "<p>An error occurred. You can close this tab.</p>"
150
+ "</body></html>"
151
+ )
152
+
153
+
154
+ def _send_html(conn: socket.socket, html: str) -> None:
155
+ response = (
156
+ f"HTTP/1.1 200 OK\r\n"
157
+ f"Content-Type: text/html\r\n"
158
+ f"Content-Length: {len(html)}\r\n"
159
+ f"Connection: close\r\n\r\n"
160
+ f"{html}"
161
+ )
162
+ conn.sendall(response.encode())
163
+ conn.close()
164
+
165
+
166
+ def _browser_auth() -> Dict[str, Any]:
167
+ verifier, challenge = _generate_pkce()
168
+ state = secrets.token_urlsafe(16)
169
+
170
+ # Bind to a random port for the OAuth callback
171
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
172
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
173
+ sock.bind(("127.0.0.1", 0))
174
+ sock.listen(1)
175
+ port = sock.getsockname()[1]
176
+ redirect_uri = f"http://localhost:{port}"
177
+
178
+ params = urlencode({
179
+ "client_id": _CLIENT_ID,
180
+ "response_type": "code",
181
+ "redirect_uri": redirect_uri,
182
+ "scope": _SCOPE,
183
+ "code_challenge": challenge,
184
+ "code_challenge_method": "S256",
185
+ "state": state,
186
+ "prompt": "select_account",
187
+ })
188
+ auth_url = f"{_AUTH_URL}?{params}"
189
+
190
+ print(f"{_LOG_PREFIX} Opening browser for Azure DevOps authentication...", file=sys.stderr)
191
+ _open_browser(auth_url)
192
+
193
+ # Wait for the OAuth callback
194
+ sock.settimeout(_CALLBACK_TIMEOUT)
195
+ try:
196
+ conn, _ = sock.accept()
197
+ except socket.timeout:
198
+ sock.close()
199
+ raise RuntimeError(f"Timed out waiting for browser callback after {_CALLBACK_TIMEOUT}s")
200
+ sock.close()
201
+ data = conn.recv(8192).decode("utf-8", errors="replace")
202
+
203
+ # Parse GET /?code=...&state=...
204
+ request_line = data.split("\r\n")[0]
205
+ path = request_line.split(" ")[1] if " " in request_line else ""
206
+ parsed = urlparse(f"http://localhost{path}")
207
+ params_dict = parse_qs(parsed.query)
208
+
209
+ if "error" in params_dict:
210
+ _send_html(conn, _ERROR_HTML)
211
+ error = params_dict["error"][0]
212
+ desc = params_dict.get("error_description", [""])[0]
213
+ raise RuntimeError(f"{error}: {desc}")
214
+
215
+ code = params_dict.get("code", [None])[0]
216
+ if not code:
217
+ _send_html(conn, _ERROR_HTML)
218
+ raise RuntimeError("Missing 'code' in OAuth callback")
219
+
220
+ returned_state = params_dict.get("state", [None])[0]
221
+ if returned_state != state:
222
+ _send_html(conn, _ERROR_HTML)
223
+ raise RuntimeError("OAuth state mismatch — possible CSRF")
224
+
225
+ _send_html(conn, _SUCCESS_HTML)
226
+
227
+ return _exchange_code(code, redirect_uri, verifier)
228
+
229
+
230
+ def _exchange_code(code: str, redirect_uri: str, verifier: str) -> Dict[str, Any]:
231
+ resp = requests.post(_TOKEN_URL, data={
232
+ "client_id": _CLIENT_ID,
233
+ "grant_type": "authorization_code",
234
+ "code": code,
235
+ "redirect_uri": redirect_uri,
236
+ "code_verifier": verifier,
237
+ }, timeout=_HTTP_TIMEOUT)
238
+ resp.raise_for_status()
239
+ return resp.json()
240
+
241
+
242
+ # ── Token refresh ────────────────────────────────────────────────────────────
243
+
244
+ def _refresh_access_token(refresh_token: str) -> Dict[str, Any]:
245
+ resp = requests.post(_TOKEN_URL, data={
246
+ "client_id": _CLIENT_ID,
247
+ "grant_type": "refresh_token",
248
+ "refresh_token": refresh_token,
249
+ "scope": _SCOPE,
250
+ }, timeout=_HTTP_TIMEOUT)
251
+ resp.raise_for_status()
252
+ return resp.json()
253
+
254
+
255
+ # ── VssSessionToken exchange ─────────────────────────────────────────────────
256
+
257
+ def _get_session_token(access_token: str, org: str) -> str:
258
+ url = f"https://vssps.dev.azure.com/{org}/_apis/token/sessiontokens?api-version=5.0-preview.1"
259
+ resp = requests.post(
260
+ url,
261
+ headers={"Authorization": f"Bearer {access_token}"},
262
+ json={"scope": "vso.packaging", "targetAccounts": []},
263
+ timeout=_HTTP_TIMEOUT,
264
+ )
265
+ resp.raise_for_status()
266
+ return resp.json()["token"]
267
+
268
+
269
+ # ── Main authenticate logic ─────────────────────────────────────────────────
270
+
271
+ def _authenticate(service_url: str) -> Optional[Tuple[str, str]]:
272
+ if not _is_devops_url(service_url):
273
+ return None
274
+
275
+ org = _extract_org(service_url)
276
+ if not org:
277
+ raise RuntimeError(
278
+ f"Could not extract Azure DevOps org from URL: {service_url}"
279
+ )
280
+
281
+ now = int(time.time())
282
+ cache = _load_cache()
283
+
284
+ # 1. Check cached session token (5-min buffer)
285
+ if cache:
286
+ st = cache.get("session_tokens", {}).get(org)
287
+ if st and st["expires_at"] > now + 300:
288
+ print(f"{_LOG_PREFIX} Using cached session token for '{org}'", file=sys.stderr)
289
+ return ("VssSessionToken", st["token"])
290
+
291
+ # 2. Get a valid access token (refresh or browser)
292
+ token_resp = None
293
+ if cache and cache.get("expires_at", 0) > now + 60:
294
+ pass # access token still valid
295
+ elif cache and cache.get("refresh_token"):
296
+ print(f"{_LOG_PREFIX} Refreshing access token...", file=sys.stderr)
297
+ try:
298
+ token_resp = _refresh_access_token(cache["refresh_token"])
299
+ except Exception as e:
300
+ print(f"{_LOG_PREFIX} Refresh failed ({e}), falling back to browser", file=sys.stderr)
301
+ token_resp = _browser_auth()
302
+ else:
303
+ print(f"{_LOG_PREFIX} No cached token, starting browser auth...", file=sys.stderr)
304
+ token_resp = _browser_auth()
305
+
306
+ # Update cache with fresh access token
307
+ if token_resp:
308
+ prev_sessions = cache.get("session_tokens", {}) if cache else {}
309
+ cache = {
310
+ "access_token": token_resp["access_token"],
311
+ "refresh_token": token_resp.get("refresh_token"),
312
+ "expires_at": now + token_resp.get("expires_in", 3600),
313
+ "session_tokens": prev_sessions,
314
+ }
315
+
316
+ assert cache is not None
317
+
318
+ # 3. Exchange for VssSessionToken
319
+ print(f"{_LOG_PREFIX} Exchanging for VssSessionToken ({org})...", file=sys.stderr)
320
+ session_token = _get_session_token(cache["access_token"], org)
321
+
322
+ cache.setdefault("session_tokens", {})[org] = {
323
+ "token": session_token,
324
+ "expires_at": now + 3000,
325
+ }
326
+ _save_cache(cache)
327
+
328
+ print(f"{_LOG_PREFIX} ✓ Authenticated to '{org}'", file=sys.stderr)
329
+ return ("VssSessionToken", session_token)
330
+
331
+
332
+ # ── Keyring backend ─────────────────────────────────────────────────────────
333
+
334
+ class AdoKeyring(keyring.backend.KeyringBackend):
335
+ """Authenticates to Azure DevOps package feeds via browser OAuth2 + PKCE.
336
+
337
+ Works on WSL, Linux, and macOS. Tokens are cached to avoid repeated prompts.
338
+ """
339
+
340
+ priority = 10 # Higher than artifacts-keyring (9.9)
341
+
342
+ def get_password(self, service: str, username: str) -> Optional[str]:
343
+ result = _authenticate(service)
344
+ return result[1] if result else None
345
+
346
+ def get_credential(self, service: str, username: str) -> Optional[keyring.credentials.SimpleCredential]:
347
+ result = _authenticate(service)
348
+ if result:
349
+ return keyring.credentials.SimpleCredential(result[0], result[1])
350
+ return None
351
+
352
+ def set_password(self, service: str, username: str, password: str) -> None:
353
+ raise NotImplementedError("ado-keyring is read-only")
354
+
355
+ def delete_password(self, service: str, username: str) -> None:
356
+ path = _cache_path()
357
+ if path.exists():
358
+ path.unlink()
359
+ print(f"{_LOG_PREFIX} Token cache cleared", file=sys.stderr)
360
+