azure-dev-proxy 0.1.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.
@@ -0,0 +1 @@
1
+ """Local Azure-authenticated HTTPS reverse proxy."""
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from .config import Config
9
+ from .proxy import Proxy
10
+
11
+
12
+ def port_number(value: str) -> int:
13
+ try:
14
+ port = int(value)
15
+ except ValueError as error:
16
+ raise argparse.ArgumentTypeError("Port must be an integer") from error
17
+ if not 1 <= port <= 65535:
18
+ raise argparse.ArgumentTypeError("Port must be between 1 and 65535")
19
+ return port
20
+
21
+
22
+ def main() -> None:
23
+ """
24
+ Run the loopback proxy or validate its configuration without listening.
25
+
26
+ POSIX supports inherited systemd listeners; otherwise the service binds its
27
+ own loopback sockets. Windows uses direct listeners and native signal handling.
28
+ """
29
+ parser = argparse.ArgumentParser(
30
+ prog="azure-dev-proxy",
31
+ description="Local HTTP(S) proxy with HTTPS-only Azure CLI tokens",
32
+ )
33
+ parser.add_argument("--version", action="version", version="azure-dev-proxy 0.1.0")
34
+ parser.add_argument(
35
+ "--port", type=port_number, default=8080, help="Loopback port (default: 8080)"
36
+ )
37
+ parser.add_argument(
38
+ "--az-path",
39
+ default="az",
40
+ help="Azure CLI executable path (default: az on PATH)",
41
+ )
42
+ parser.add_argument(
43
+ "--config",
44
+ type=Path,
45
+ help="JSON configuration (default: the user's azure-dev-proxy/config.json)",
46
+ )
47
+ parser.add_argument(
48
+ "--idle-timeout",
49
+ type=idle_seconds,
50
+ default=0,
51
+ help="Opt-in idle shutdown seconds (default: 0, disabled)",
52
+ )
53
+ auth = parser.add_mutually_exclusive_group()
54
+ auth.add_argument(
55
+ "--token", help="Proxy authentication key (or AZURE_DEV_PROXY_TOKEN)"
56
+ )
57
+ auth.add_argument(
58
+ "--token-file",
59
+ type=Path,
60
+ help="Saved key file (default: auth-token beside the configuration)",
61
+ )
62
+ auth.add_argument(
63
+ "--no-auth", action="store_true", help="Disable proxy authentication"
64
+ )
65
+ parser.add_argument(
66
+ "--check-config",
67
+ action="store_true",
68
+ help="Validate configuration and exit without listening",
69
+ )
70
+ args = parser.parse_args()
71
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
72
+ try:
73
+ if not args.check_config and hasattr(os, "geteuid") and os.geteuid() == 0:
74
+ raise ValueError("Run azure-dev-proxy as a non-root user")
75
+ if args.check_config:
76
+ Config.load(
77
+ args.config if args.config is not None else Config.default_path()
78
+ )
79
+ return
80
+ proxy = Proxy(
81
+ config_path=args.config,
82
+ az_path=args.az_path,
83
+ token=args.token,
84
+ token_file=args.token_file,
85
+ no_auth=args.no_auth,
86
+ )
87
+ proxy.serve(args.port, idle_timeout=args.idle_timeout)
88
+ except (OSError, ValueError, RuntimeError) as error:
89
+ parser.exit(1, f"Could not start azure-dev-proxy: {error}\n")
90
+
91
+
92
+ def idle_seconds(value: str) -> int:
93
+ """Parse nonnegative idle seconds; zero disables automatic shutdown."""
94
+ try:
95
+ seconds = int(value)
96
+ except ValueError:
97
+ raise argparse.ArgumentTypeError("Idle timeout must be an integer") from None
98
+ if seconds < 0:
99
+ raise argparse.ArgumentTypeError("Idle timeout must be nonnegative")
100
+ return seconds
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
@@ -0,0 +1,382 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import html
6
+ import os
7
+ import re
8
+ import secrets
9
+ import stat
10
+ import tempfile
11
+ import time
12
+ from importlib.resources import files
13
+ from pathlib import Path
14
+ from urllib.parse import unquote_plus, urlencode
15
+
16
+ from aiohttp import web
17
+
18
+ from .config import Config
19
+
20
+ TOKEN_PARAMETER = "azure-dev-proxy-token"
21
+ TOKEN_HEADER = "X-Azure-Dev-Proxy-Token"
22
+ LOGIN_PATH = "/:login"
23
+ SESSION_PATH = "/:session"
24
+ ROUTE_KEY = web.RequestKey("authenticated_route", str)
25
+ SESSION_KEY = web.RequestKey("new_session", bool)
26
+ HANDOFF_KEY = web.RequestKey("session_handoff", bool)
27
+ LOGIN_HEADERS = {
28
+ "Cache-Control": "no-store",
29
+ "X-Content-Type-Options": "nosniff",
30
+ "Referrer-Policy": "strict-origin",
31
+ "Content-Security-Policy": (
32
+ "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; "
33
+ "frame-ancestors 'none'; base-uri 'none'"
34
+ ),
35
+ }
36
+
37
+
38
+ class ProxyAuthentication:
39
+ """
40
+ Shared-key access to the local proxy and host-bound browser sessions.
41
+
42
+ A null key explicitly disables authentication. Session cookies remain valid
43
+ across proxy restarts with the same key, hostname and port, until the browser
44
+ discards them. These credentials do not isolate mutually untrusted processes
45
+ running as the same operating-system user.
46
+ """
47
+
48
+ def __init__(self, token: str | None) -> None:
49
+ if token is not None and (
50
+ not token
51
+ or len(token) > 4096
52
+ or any(ord(char) < 32 or ord(char) == 127 for char in token)
53
+ ):
54
+ raise ValueError(
55
+ "The proxy authentication key must be nonempty and contain no controls"
56
+ )
57
+ self._key = token.encode("utf-8") if token is not None else None
58
+ self._handoffs: dict[str, tuple[str, str, float]] = {}
59
+
60
+ @classmethod
61
+ def load(
62
+ cls,
63
+ path: Path,
64
+ *,
65
+ token: str | None = None,
66
+ disabled: bool = False,
67
+ ) -> ProxyAuthentication:
68
+ """
69
+ Load a saved key, creating a private random key file if it is missing.
70
+
71
+ Args:
72
+ path: Key file; generated files are readable only by their owner
73
+ on POSIX. Existing POSIX files must belong to the current user.
74
+ Windows uses the containing directory's inherited ACL.
75
+ token: Explicit key instead of a key file, or null to use the file.
76
+ disabled: Disable authentication without reading or creating a key.
77
+ """
78
+ if disabled or token is not None:
79
+ return cls(None if disabled else token)
80
+ if path.is_symlink():
81
+ raise ValueError("The authentication key file must not be a symbolic link")
82
+ if not path.exists():
83
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
84
+ with tempfile.NamedTemporaryFile(
85
+ mode="w", encoding="utf-8", dir=path.parent
86
+ ) as temporary:
87
+ temporary.write(secrets.token_urlsafe(32) + "\n")
88
+ temporary.flush()
89
+ os.fsync(temporary.fileno())
90
+ try:
91
+ os.link(temporary.name, path)
92
+ except FileExistsError:
93
+ pass
94
+ flags = (
95
+ os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
96
+ )
97
+ # Validate and read the same inode even if the pathname is replaced.
98
+ descriptor = os.open(path, flags)
99
+ try:
100
+ metadata = os.fstat(descriptor)
101
+ if not stat.S_ISREG(metadata.st_mode):
102
+ raise ValueError("The authentication key file must be a regular file")
103
+ if os.name != "nt":
104
+ if metadata.st_uid != os.geteuid():
105
+ raise ValueError(
106
+ "The authentication key file must belong to the current user"
107
+ )
108
+ if metadata.st_mode & 0o077:
109
+ raise ValueError(
110
+ "The authentication key file must be accessible only to its owner"
111
+ )
112
+ with os.fdopen(descriptor, encoding="utf-8", closefd=False) as key_file:
113
+ return cls(key_file.read().rstrip("\r\n"))
114
+ finally:
115
+ os.close(descriptor)
116
+
117
+ @property
118
+ def enabled(self) -> bool:
119
+ """Whether access requires a key or an authenticated browser session."""
120
+ return self._key is not None
121
+
122
+ def login_url(self, origin: str) -> str:
123
+ """Return a key-bearing startup URL, or the plain UI URL when disabled."""
124
+ origin = Config.normalize_origin(origin)
125
+ if self._key is None:
126
+ return origin + "/"
127
+ return origin + "/?" + urlencode({TOKEN_PARAMETER: self._key.decode("utf-8")})
128
+
129
+ @staticmethod
130
+ def strip_query_token(raw_path: str) -> tuple[str, tuple[str, ...]]:
131
+ """Remove reserved query fields, preserving other parameters' raw encoding."""
132
+ path, separator, query = raw_path.partition("?")
133
+ if not separator:
134
+ return raw_path, ()
135
+ remaining = []
136
+ tokens = []
137
+ for field in query.split("&"):
138
+ key, _, value = field.partition("=")
139
+ if unquote_plus(key) == TOKEN_PARAMETER:
140
+ tokens.append(unquote_plus(value))
141
+ else:
142
+ remaining.append(field)
143
+ return path + ("?" + "&".join(remaining) if remaining else ""), tuple(tokens)
144
+
145
+ def _matches(self, token: str) -> bool:
146
+ return self._key is not None and secrets.compare_digest(
147
+ self._key,
148
+ token.encode("utf-8"),
149
+ )
150
+
151
+ @staticmethod
152
+ def _cookie_name(request: web.Request) -> str:
153
+ return f"aad-dev-proxy-session-{request.url.port}"
154
+
155
+ def _signature(self, request: web.Request, nonce: str) -> str:
156
+ if self._key is None:
157
+ raise RuntimeError("Authentication is disabled")
158
+ origin = Config.normalize_origin("http://" + request.headers["Host"])
159
+ return hmac.new(
160
+ self._key,
161
+ f"browser-session:{origin}:{nonce}".encode(),
162
+ hashlib.sha256,
163
+ ).hexdigest()
164
+
165
+ def _has_session(self, request: web.Request) -> bool:
166
+ cookie = request.cookies.get(self._cookie_name(request), "")
167
+ if not re.fullmatch(r"[A-Za-z0-9_-]{43}\.[a-f0-9]{64}", cookie):
168
+ return False
169
+ nonce, signature = cookie.split(".")
170
+ return secrets.compare_digest(signature, self._signature(request, nonce))
171
+
172
+ def prepare_response(
173
+ self, request: web.Request, response: web.StreamResponse
174
+ ) -> None:
175
+ """Apply private caching and issue a host-only, HttpOnly session if requested."""
176
+ if not self.enabled:
177
+ return
178
+ response.headers["Cache-Control"] = "no-store"
179
+ if request.get(SESSION_KEY, False):
180
+ nonce = secrets.token_urlsafe(32)
181
+ response.set_cookie(
182
+ self._cookie_name(request),
183
+ nonce + "." + self._signature(request, nonce),
184
+ httponly=True,
185
+ samesite="Lax",
186
+ path="/",
187
+ )
188
+ # aiohttp has already serialized cookies when response-prepare hooks run.
189
+ response.headers.add(
190
+ "Set-Cookie",
191
+ response.cookies[self._cookie_name(request)].OutputString(),
192
+ )
193
+
194
+ def login_page(
195
+ self, request: web.Request, *, invalid: bool = False
196
+ ) -> web.Response:
197
+ """Prompt for a key without exposing it in the document or return URL."""
198
+ page = (
199
+ files("azure_dev_proxy")
200
+ .joinpath("static/login.html")
201
+ .read_text(encoding="utf-8")
202
+ )
203
+ page = page.replace(
204
+ "__ERROR__", "Invalid authentication key." if invalid else ""
205
+ )
206
+ page = page.replace("__NEXT__", html.escape(request[ROUTE_KEY], quote=True))
207
+ return web.Response(
208
+ text=page, status=401, content_type="text/html", headers=LOGIN_HEADERS
209
+ )
210
+
211
+ @staticmethod
212
+ def _redirect(location: str) -> web.Response:
213
+ return web.Response(
214
+ status=303,
215
+ headers={
216
+ **LOGIN_HEADERS,
217
+ "Referrer-Policy": "origin",
218
+ "Location": location,
219
+ },
220
+ )
221
+
222
+ @staticmethod
223
+ def _return_path(value: object) -> str:
224
+ if (
225
+ not isinstance(value, str)
226
+ or not value.startswith("/")
227
+ or value.startswith("//")
228
+ or any(
229
+ char == "\\" or ord(char) <= 32 or ord(char) == 127 for char in value
230
+ )
231
+ ):
232
+ raise web.HTTPBadRequest(text="Invalid login return path\n")
233
+ return ProxyAuthentication.strip_query_token(value)[0]
234
+
235
+ @staticmethod
236
+ def _other_origin(request: web.Request) -> str:
237
+ host = "127.0.0.1" if request.url.host == "localhost" else "localhost"
238
+ return f"http://{host}:{request.url.port}"
239
+
240
+ def is_handoff(self, request: web.Request, query_tokens: tuple[str, ...]) -> bool:
241
+ """Recognize a destination-bound GET handoff before browser-origin checks."""
242
+ if (
243
+ not self.enabled
244
+ or len(query_tokens) != 1
245
+ or request.method not in {"GET", "HEAD"}
246
+ ):
247
+ return False
248
+ if request.headers.get("Sec-Fetch-Mode") not in {None, "navigate"}:
249
+ return False
250
+ if request.headers.get("Sec-Fetch-Dest") not in {None, "document"}:
251
+ return False
252
+ token = query_tokens[0]
253
+ expected = self._handoffs.get(token)
254
+ if expected is None:
255
+ return False
256
+ origin, route, expires = expected
257
+ if (
258
+ time.monotonic() >= expires
259
+ or Config.normalize_origin("http://" + request.headers["Host"]) != origin
260
+ or request[ROUTE_KEY] != route
261
+ ):
262
+ return False
263
+ return True
264
+
265
+ def _consume_handoff(self, request: web.Request, token: str) -> bool:
266
+ if not self.is_handoff(request, (token,)):
267
+ return False
268
+ del self._handoffs[token]
269
+ return True
270
+
271
+ @staticmethod
272
+ def _continue_navigation(request: web.Request) -> web.Response:
273
+ # A fresh document avoids form-action redirect restrictions and Origin:null.
274
+ route = html.escape(request[ROUTE_KEY], quote=True)
275
+ return web.Response(
276
+ text=(
277
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">'
278
+ f'<meta http-equiv="refresh" content="0;url={route}">'
279
+ "<title>Signing in - Azure Dev Proxy</title></head>"
280
+ f'<body><a href="{route}">Continue</a></body></html>'
281
+ ),
282
+ content_type="text/html",
283
+ headers=LOGIN_HEADERS,
284
+ )
285
+
286
+ def _handoff(self, request: web.Request) -> web.Response:
287
+ values = request.query.getall("next", [])
288
+ if len(values) != 1:
289
+ raise web.HTTPBadRequest(text="A single session return path is required\n")
290
+ route = self._return_path(values[0])
291
+ if route.partition("?")[0] == SESSION_PATH:
292
+ raise web.HTTPBadRequest(
293
+ text="Recursive session handoff is not supported\n"
294
+ )
295
+ return self.handoff_response(request, route)
296
+
297
+ def handoff_response(self, request: web.Request, route: str) -> web.Response:
298
+ """Pair the other loopback hostname for an already-authenticated GET."""
299
+ now = time.monotonic()
300
+ self._handoffs = {
301
+ token: entry for token, entry in self._handoffs.items() if entry[2] > now
302
+ }
303
+ if len(self._handoffs) >= 1024:
304
+ del self._handoffs[next(iter(self._handoffs))]
305
+ token = "session-" + secrets.token_urlsafe(32)
306
+ origin = self._other_origin(request)
307
+ self._handoffs[token] = (origin, route, now + 60)
308
+ separator = "&" if "?" in route else "?"
309
+ return self._redirect(
310
+ origin + route + separator + urlencode({TOKEN_PARAMETER: token})
311
+ )
312
+
313
+ async def authorize(
314
+ self,
315
+ request: web.Request,
316
+ query_tokens: tuple[str, ...],
317
+ ) -> web.Response | None:
318
+ """Authenticate before local management, GraphiQL rendering or forwarding."""
319
+ if not self.enabled:
320
+ return None
321
+ if request.path == SESSION_PATH and request.method not in {"GET", "HEAD"}:
322
+ raise web.HTTPMethodNotAllowed(request.method, ["GET", "HEAD"])
323
+ if request.path == LOGIN_PATH:
324
+ if request.method != "POST":
325
+ raise web.HTTPMethodNotAllowed(request.method, ["POST"])
326
+ origin = request.headers.get("Origin")
327
+ if origin is not None and origin != "http://" + request.headers["Host"]:
328
+ raise web.HTTPForbidden(text="Local same-origin login required\n")
329
+ if request.content_type != "application/x-www-form-urlencoded":
330
+ raise web.HTTPBadRequest(text="A URL-encoded login form is required\n")
331
+ form = await request.post()
332
+ tokens = form.getall("token", [])
333
+ request[ROUTE_KEY] = self._return_path(form.get("next", "/"))
334
+ if (
335
+ len(tokens) != 1
336
+ or not isinstance(tokens[0], str)
337
+ or not self._matches(tokens[0])
338
+ ):
339
+ return self.login_page(request, invalid=True)
340
+ request[SESSION_KEY] = True
341
+ return self._continue_navigation(request)
342
+ header_tokens = request.headers.getall(TOKEN_HEADER, [])
343
+ if len(query_tokens) > 1 or len(header_tokens) > 1:
344
+ raise web.HTTPBadRequest(text="Supply a single proxy authentication key\n")
345
+ supplied = (*query_tokens, *header_tokens)
346
+ if supplied:
347
+ valid = all(self._matches(token) for token in header_tokens) and all(
348
+ self._matches(token) or self._consume_handoff(request, token)
349
+ for token in query_tokens
350
+ )
351
+ else:
352
+ valid = self._has_session(request)
353
+ if not valid:
354
+ if request.method in {"GET", "HEAD"}:
355
+ if (
356
+ not supplied
357
+ and request.path != SESSION_PATH
358
+ and request.headers.get("Sec-Fetch-Mode") == "navigate"
359
+ and request.headers.get("Sec-Fetch-Dest") == "document"
360
+ ):
361
+ return self._redirect(
362
+ self._other_origin(request)
363
+ + SESSION_PATH
364
+ + "?"
365
+ + urlencode({"next": request[ROUTE_KEY]}),
366
+ )
367
+ return self.login_page(request, invalid=bool(supplied))
368
+ return web.Response(
369
+ status=401,
370
+ text="Proxy authentication required\n",
371
+ headers=LOGIN_HEADERS,
372
+ )
373
+ if query_tokens:
374
+ request[SESSION_KEY] = True
375
+ if request.method in {"GET", "HEAD"}:
376
+ if request.get(HANDOFF_KEY, False):
377
+ return self._continue_navigation(request)
378
+ if request.headers.get("Sec-Fetch-Mode") == "navigate":
379
+ return self._redirect(request[ROUTE_KEY])
380
+ if request.path == SESSION_PATH:
381
+ return self._handoff(request)
382
+ return None