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.
- ado_keyring-0.1.0/.github/workflows/ci.yml +18 -0
- ado_keyring-0.1.0/.github/workflows/pypi.yml +35 -0
- ado_keyring-0.1.0/.gitignore +7 -0
- ado_keyring-0.1.0/LICENSE +21 -0
- ado_keyring-0.1.0/PKG-INFO +8 -0
- ado_keyring-0.1.0/README.md +50 -0
- ado_keyring-0.1.0/justfile +28 -0
- ado_keyring-0.1.0/pyproject.toml +22 -0
- ado_keyring-0.1.0/python/ado_keyring/__init__.py +360 -0
- ado_keyring-0.1.0/tests/test_ado_keyring.py +210 -0
- ado_keyring-0.1.0/uv.lock +633 -0
|
@@ -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,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,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
|
+
|