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,124 @@
1
+ "use strict";
2
+
3
+ const api = "/:config";
4
+ const editable = document.documentElement.dataset.editable === "true";
5
+ const forwardOrigin = document.querySelector('meta[name="azure-dev-proxy-forward-origin"]').content;
6
+ let csrf = document.querySelector('meta[name="azure-dev-proxy-csrf"]').content;
7
+ const status = document.getElementById("status");
8
+
9
+ function message(text, error = false) {
10
+ status.textContent = text;
11
+ status.classList.toggle("error", error);
12
+ }
13
+
14
+ function rows(id, rules, actions = []) {
15
+ const body = document.getElementById(id);
16
+ body.replaceChildren();
17
+ for (const rule of rules) {
18
+ const row = document.createElement("tr");
19
+ for (const text of [rule.host, rule.resource ?? "Destination HTTPS origin"]) {
20
+ const cell = document.createElement("td");
21
+ cell.textContent = text;
22
+ row.append(cell);
23
+ }
24
+ if (actions.length) {
25
+ const cell = document.createElement("td");
26
+ for (const action of actions) {
27
+ const button = document.createElement("button");
28
+ button.type = "button";
29
+ button.className = "secondary";
30
+ button.textContent = action.label;
31
+ button.setAttribute("aria-label", `${action.label} for ${rule.host}`);
32
+ button.addEventListener("click", () => save(action.method, {host: rule.host}, action.persisted));
33
+ cell.append(button);
34
+ }
35
+ row.append(cell);
36
+ }
37
+ body.append(row);
38
+ }
39
+ if (!rules.length) {
40
+ const row = body.insertRow();
41
+ const cell = row.insertCell();
42
+ cell.colSpan = actions.length ? 3 : 2;
43
+ cell.textContent = "No rules configured.";
44
+ }
45
+ }
46
+
47
+ function render(config) {
48
+ const canPersist = editable && config.persistence_available;
49
+ rows("base-rules", config.base_rules, canPersist
50
+ ? [{label: "Delete persisted", method: "DELETE", persisted: true}] : []);
51
+ const temporaryActions = editable ? [{label: "Remove temporary", method: "DELETE"}] : [];
52
+ if (canPersist) temporaryActions.unshift({label: "Save to persisted", method: "POST", persisted: true});
53
+ rows("overrides", config.overrides, temporaryActions);
54
+ rows("effective-rules", config.effective_rules);
55
+ }
56
+
57
+ async function refresh() {
58
+ // A socket-triggered restart changes the CSRF token; never retain a stale one.
59
+ if (editable) {
60
+ const page = await fetch("/", {cache: "no-store", credentials: "same-origin"});
61
+ if (!page.ok) throw new Error("Could not refresh the local session.");
62
+ const html = new DOMParser().parseFromString(await page.text(), "text/html");
63
+ csrf = html.querySelector('meta[name="azure-dev-proxy-csrf"]').content;
64
+ }
65
+ const result = await fetch(api, {cache: "no-store", credentials: "same-origin"});
66
+ if (!result.ok) throw new Error("Could not read host mappings.");
67
+ render(await result.json());
68
+ }
69
+
70
+ async function save(method, value, persisted = false) {
71
+ if (!editable) {
72
+ location.assign(document.getElementById("edit-link").href);
73
+ return;
74
+ }
75
+ try {
76
+ await refresh();
77
+ const result = await fetch(persisted ? `${api}/persisted` : api, {
78
+ method,
79
+ credentials: "same-origin",
80
+ headers: {"Content-Type": "application/json", "X-Azure-Proxy-CSRF": csrf},
81
+ body: JSON.stringify(value),
82
+ });
83
+ if (!result.ok) throw new Error((await result.text()).trim());
84
+ render(await result.json());
85
+ message(persisted
86
+ ? "Persisted configuration saved to disk and applied."
87
+ : "Temporary overrides updated. They disappear on process exit, including idle shutdown.");
88
+ } catch (error) {
89
+ message(error.message, true);
90
+ }
91
+ }
92
+
93
+ document.getElementById("navigate").addEventListener("submit", (event) => {
94
+ event.preventDefault();
95
+ try {
96
+ const raw = document.getElementById("destination").value.trim();
97
+ const value = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
98
+ const url = new URL(value);
99
+ if (!/^https?:\/\//i.test(value) || !["http:", "https:"].includes(url.protocol) || url.username || url.password || raw.includes("\\")) {
100
+ throw new Error("Enter an HTTP(S) URL without credentials or backslashes.");
101
+ }
102
+ location.assign(`${forwardOrigin}/${url.protocol}//${url.host}${url.pathname}${url.search}${url.hash}`);
103
+ } catch (error) {
104
+ message(error.message, true);
105
+ }
106
+ });
107
+
108
+ document.getElementById("override").addEventListener("submit", (event) => {
109
+ event.preventDefault();
110
+ save("POST", {
111
+ host: document.getElementById("hostname").value.trim(),
112
+ resource: document.getElementById("resource").value.trim() || null,
113
+ });
114
+ });
115
+
116
+ document.querySelector("details").addEventListener("toggle", async (event) => {
117
+ if (!event.target.open) return;
118
+ try {
119
+ await refresh();
120
+ message("");
121
+ } catch (error) {
122
+ message(error.message, true);
123
+ }
124
+ });
@@ -0,0 +1,109 @@
1
+ <!--
2
+ * Adapted from the supplied GraphiQL example, licensed under the MIT license:
3
+ * https://github.com/graphql/graphiql/blob/main/LICENSE
4
+ *
5
+ * MIT License
6
+ *
7
+ * Copyright (c) GraphQL Contributors
8
+ *
9
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ * of this software and associated documentation files (the "Software"), to deal
11
+ * in the Software without restriction, including without limitation the rights
12
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ * copies of the Software, and to permit persons to whom the Software is
14
+ * furnished to do so, subject to the following conditions:
15
+ *
16
+ * The above copyright notice and this permission notice shall be included in all
17
+ * copies or substantial portions of the Software.
18
+ *
19
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ * SOFTWARE.
26
+ -->
27
+ <!doctype html>
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="UTF-8" />
31
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
32
+ <title>GraphiQL - Azure auth proxy</title>
33
+ <style>
34
+ body { margin: 0; }
35
+ #graphiql { height: 100dvh; }
36
+ .loading {
37
+ height: 100%;
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ color-scheme: light dark;
42
+ background: Canvas;
43
+ color: CanvasText;
44
+ font: 14px/1.5 system-ui, sans-serif;
45
+ }
46
+ </style>
47
+ <link
48
+ rel="stylesheet"
49
+ href="https://esm.sh/graphiql@6.0.0/dist/style.css"
50
+ integrity="sha384-pHzXVzR6TL0s+jcKLsjQ4WlRVmR8RPyAJsLyyym4TLpYnIWUPf20JavS0VxzUz1Y"
51
+ crossorigin="anonymous"
52
+ />
53
+ <script type="importmap" nonce="__NONCE__">
54
+ {
55
+ "imports": {
56
+ "react": "https://esm.sh/react@19.3.1",
57
+ "react/": "https://esm.sh/react@19.3.1/",
58
+ "react-dom": "https://esm.sh/react-dom@19.3.1",
59
+ "react-dom/": "https://esm.sh/react-dom@19.3.1/",
60
+ "graphiql": "https://esm.sh/graphiql@6.0.0?standalone&external=react,react-dom,@graphiql/react,graphql",
61
+ "graphiql/": "https://esm.sh/graphiql@6.0.0/",
62
+ "graphiql/setup-workers/esm.sh": "https://esm.sh/graphiql@6.0.0/setup-workers/esm.sh?deps=@graphiql/react@1.0.1",
63
+ "@graphiql/react": "https://esm.sh/@graphiql/react@1.0.1?standalone&external=react,react-dom,graphql,@graphiql/toolkit,@emotion/is-prop-valid",
64
+ "@graphiql/toolkit": "https://esm.sh/@graphiql/toolkit@1.0.0?standalone&external=graphql",
65
+ "graphql": "https://esm.sh/graphql@17.0.2",
66
+ "@emotion/is-prop-valid": "data:text/javascript,"
67
+ },
68
+ "integrity": {
69
+ "https://esm.sh/react@19.3.1": "sha384-ut1U+oIs0ClaOE7uF1+DZAaHVvYvEJZME3mUuqn95yVqQ70a+8u2LN2LD37JgJkO",
70
+ "https://esm.sh/react-dom@19.3.1": "sha384-FKbbSw/nINZRM0ej/iwjfXbJeAZMJdBKqKmn/I+aIM4Io5qupmM8sZJxaJZDO0sv",
71
+ "https://esm.sh/graphiql@6.0.0": "sha384-4t9K5VrZG+S+yt/m4xfOIM5zsF8FMY4l2Aftx55bVhOvtBuLThHoSK3eammwCVAR",
72
+ "https://esm.sh/graphiql@6.0.0/setup-workers/esm.sh?deps=@graphiql/react@1.0.1": "sha384-uRZR11cNRp3axJoK9E9JWMf12kxXKGZBy7exaCZkEc3uP6sbi5f6W7RSYth46lVu",
73
+ "https://esm.sh/graphiql@6.0.0?standalone&external=react,react-dom,@graphiql/react,graphql": "sha384-Cyeb/6CXzM443S0753DNctp2rJbXK7Yh2n9mzeYa+e7qMIqJb0XFRgw4wBFEXNfW",
74
+ "https://esm.sh/@graphiql/react@1.0.1?standalone&external=react,react-dom,graphql,@graphiql/toolkit,@emotion/is-prop-valid": "sha384-J+G5cflCBCPKFjkS8Lt7qA6W39wy/LSB09Pzb+9DgLqZ1XcDlyrMbfZ/pBqZVXlc",
75
+ "https://esm.sh/@graphiql/toolkit@1.0.0?standalone&external=graphql": "sha384-pW1hwiHXozNQ020c1EOLfZkjDANM06ecupsD3iPgx8Az8u2zlYrOL9JhjsjsyMOu",
76
+ "https://esm.sh/graphql@17.0.2": "sha384-IPvlcmnvWT91sKqwbg9MxPXJUuPmSdwXCp22x6hcbBkAEhlfSKkMLR3v19K9iRUp"
77
+ }
78
+ }
79
+ </script>
80
+ <script type="module" nonce="__NONCE__">
81
+ import React from 'react';
82
+ import ReactDOM from 'react-dom/client';
83
+ import { GraphiQL } from 'graphiql';
84
+ import { createTransport } from '@graphiql/toolkit';
85
+ import 'graphiql/setup-workers/esm.sh';
86
+
87
+ const transport = createTransport({
88
+ url: __TRANSPORT_URL__,
89
+ fetch: (url, options) => fetch(`/${url}`, options),
90
+ });
91
+
92
+ function App() {
93
+ return React.createElement(GraphiQL, {
94
+ transport,
95
+ defaultEditorToolsVisibility: true,
96
+ });
97
+ }
98
+
99
+ const container = document.getElementById('graphiql');
100
+ const root = ReactDOM.createRoot(container);
101
+ root.render(React.createElement(App));
102
+ </script>
103
+ </head>
104
+ <body>
105
+ <div id="graphiql">
106
+ <div class="loading" role="status">Loading GraphiQL...</div>
107
+ </div>
108
+ </body>
109
+ </html>
@@ -0,0 +1,90 @@
1
+ <!doctype html>
2
+ <html lang="en" data-editable="__EDITABLE__">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="azure-dev-proxy-csrf" content="__CSRF__">
7
+ <meta name="azure-dev-proxy-forward-origin" content="__FORWARD_ORIGIN__">
8
+ <title>Azure Dev Proxy</title>
9
+ <style>
10
+ :root { color-scheme: light dark; font: 16px/1.6 system-ui, sans-serif; --muted: #66758b; --line: #cbd5e1; --panel: #fff; --ink: #14253d; --accent: #1266df; background: #edf3fb; color: var(--ink); }
11
+ * { box-sizing: border-box; }
12
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 2rem 1rem; }
13
+ main { width: min(100%, 760px); }
14
+ header { text-align: center; margin-bottom: 2rem; }
15
+ .badge { color: var(--accent); font-size: .75rem; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; }
16
+ h1 { font-size: clamp(2rem, 6vw, 3rem); margin: .3rem 0; letter-spacing: -.04em; }
17
+ p { color: var(--muted); margin: .5rem 0 1rem; }
18
+ a { color: var(--accent); }
19
+ section, details { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 1.5rem; box-shadow: 0 12px 40px #14253d08; }
20
+ details { margin-top: 1rem; }
21
+ summary { cursor: pointer; font-weight: 650; }
22
+ label { display: block; font-size: .85rem; font-weight: 600; margin-bottom: .4rem; }
23
+ input, button { font: inherit; border-radius: 8px; padding: .7rem .85rem; }
24
+ input { width: 100%; min-width: 0; border: 1px solid var(--line); background: var(--panel); color: inherit; }
25
+ input:focus-visible, button:focus-visible, summary:focus-visible { outline: 3px solid #73a8f5; outline-offset: 3px; }
26
+ button { border: 0; background: var(--accent); color: #fff; cursor: pointer; font-weight: 600; white-space: nowrap; }
27
+ button.secondary { background: transparent; border: 1px solid var(--line); color: inherit; padding: .25rem .6rem; font-size: .8rem; }
28
+ .row { display: flex; gap: .6rem; }
29
+ .field { flex: 1; min-width: 0; }
30
+ .note { font-size: .8rem; margin-top: .8rem; }
31
+ .warning { border-left: 3px solid #d18c16; padding-left: .8rem; }
32
+ h2 { font-size: 1rem; margin: 1.5rem 0 .5rem; }
33
+ .table-wrap { overflow-x: auto; }
34
+ table { width: 100%; border-collapse: collapse; font-size: .85rem; text-align: left; }
35
+ th, td { padding: .65rem .4rem; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
36
+ th { color: var(--muted); font-weight: 500; }
37
+ #status { min-height: 1.6em; font-size: .85rem; color: var(--muted); }
38
+ #status.error { color: #bd2828; }
39
+ footer { text-align: center; color: var(--muted); font-size: .75rem; margin-top: 1.5rem; }
40
+ @media (max-width: 540px) { .row { flex-direction: column; } section, details { padding: 1rem; } }
41
+ @media (prefers-color-scheme: dark) { :root { --muted: #a7b7cb; --line: #33445b; --panel: #172338; --ink: #e8effa; --accent: #397fe9; background: #0e1726; } }
42
+ </style>
43
+ <script src="/:app.js" defer></script>
44
+ </head>
45
+ <body>
46
+ <main>
47
+ <header>
48
+ <span class="badge">Local Azure authentication</span>
49
+ <h1>Azure Dev Proxy</h1>
50
+ <p>Your destination. Your Azure CLI identity.</p>
51
+ </header>
52
+ <section aria-label="Open a URL">
53
+ <form id="navigate">
54
+ <label for="destination">Paste an HTTP(S) URL</label>
55
+ <div class="row">
56
+ <input id="destination" type="text" inputmode="url" placeholder="example.com/path" required autocomplete="off" spellcheck="false">
57
+ <button type="submit">Open URL</button>
58
+ </div>
59
+ </form>
60
+ <p class="note">URLs without a scheme use HTTPS. HTTP destinations are always anonymous. HTTPS authentication follows the host mappings below.</p>
61
+ </section>
62
+ <details>
63
+ <summary>Host mappings and temporary overrides</summary>
64
+ <p class="note"><a id="edit-link" href="__EDIT_URL__">Edit mappings on localhost</a>. Forwarded pages use a separate, read-only loopback origin.</p>
65
+ <p class="note">Overrides take priority over persisted rules. Within overrides: exact hosts first, then the longest wildcard suffix. The first matching rule wins.</p>
66
+ <p class="note warning"><strong>Temporary means temporary.</strong> Overrides disappear when this process exits, including automatic idle shutdown. Active requests and streams prevent idle shutdown; an open page does not.</p>
67
+ <h2>Persisted configuration</h2>
68
+ <p class="note">Saving a temporary rule replaces its persisted mapping and removes the temporary override. Persisted changes update the configuration file and apply immediately.</p>
69
+ <div class="table-wrap"><table><thead><tr><th scope="col">Hostname</th><th scope="col">Token resource</th><th scope="col" __EDITOR_HIDDEN__>Action</th></tr></thead><tbody id="base-rules"></tbody></table></div>
70
+ <h2>Temporary overrides</h2>
71
+ <div class="table-wrap"><table><thead><tr><th scope="col">Hostname</th><th scope="col">Token resource</th><th scope="col" __EDITOR_HIDDEN__>Action</th></tr></thead><tbody id="overrides"></tbody></table></div>
72
+ <div __EDITOR_HIDDEN__>
73
+ <h2>Add or replace an override</h2>
74
+ <form id="override">
75
+ <div class="row">
76
+ <div class="field"><label for="hostname">Hostname or wildcard</label><input id="hostname" placeholder="api.example.com or *.example.com" required spellcheck="false"></div>
77
+ <div class="field"><label for="resource">HTTPS token resource</label><input id="resource" type="url" placeholder="Blank: destination origin" spellcheck="false"></div>
78
+ </div>
79
+ <p class="note">A blank resource requests a token for the destination's HTTPS origin.</p>
80
+ <button type="submit">Save temporary override</button>
81
+ </form>
82
+ </div>
83
+ <h2>Effective rules (match order)</h2>
84
+ <div class="table-wrap"><table><thead><tr><th scope="col">Hostname</th><th scope="col">Token resource</th></tr></thead><tbody id="effective-rules"></tbody></table></div>
85
+ </details>
86
+ <p id="status" role="status" aria-live="polite"></p>
87
+ <footer class="warning" __AUTH_WARNING_HIDDEN__><strong>Token authentication disabled:</strong> Other local users and trusted browser origins can use this proxy's Azure identity. Do not use on shared machines.</footer>
88
+ </main>
89
+ </body>
90
+ </html>
@@ -0,0 +1,26 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Sign in - Azure Dev Proxy</title>
7
+ <style>
8
+ :root { color-scheme: light dark; font: 16px/1.6 system-ui, sans-serif; }
9
+ body { max-width: 32rem; margin: 12vh auto; padding: 1rem; }
10
+ input, button { box-sizing: border-box; font: inherit; padding: .6rem; }
11
+ input { width: 100%; margin: .5rem 0 1rem; }
12
+ .error { color: #bd2828; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <h1>Azure Dev Proxy</h1>
17
+ <p>Enter the proxy authentication key to continue.</p>
18
+ <p class="error" role="alert">__ERROR__</p>
19
+ <form method="post" action="/:login">
20
+ <input type="hidden" name="next" value="__NEXT__">
21
+ <label for="token">Authentication key</label>
22
+ <input id="token" name="token" type="password" required autofocus autocomplete="current-password">
23
+ <button type="submit">Sign in</button>
24
+ </form>
25
+ </body>
26
+ </html>
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import math
7
+ import os
8
+ import re
9
+ import shutil
10
+ import signal
11
+ import time
12
+ from collections import OrderedDict
13
+ from collections.abc import Awaitable, Callable
14
+ from dataclasses import dataclass
15
+ from typing import Literal
16
+ from urllib.parse import urlsplit
17
+
18
+ LOG = logging.getLogger(__name__)
19
+
20
+
21
+ class AcquisitionError(Exception):
22
+ """Sanitized category of token acquisition failure."""
23
+
24
+
25
+ def cli_command(executable: str, resource: str, *, windows: bool) -> list[str]:
26
+ arguments = [
27
+ "account",
28
+ "get-access-token",
29
+ "--resource",
30
+ resource,
31
+ "--output",
32
+ "json",
33
+ ]
34
+ if windows and executable.lower().endswith((".cmd", ".bat")):
35
+ # cmd expands metacharacters even inside some quoted arguments.
36
+ if any(c in executable for c in '"%!\r\n&|<>^') or not re.fullmatch(
37
+ r"https://[a-zA-Z0-9.:\[\]-]+(?:/[a-zA-Z0-9._~/-]*)?", resource
38
+ ):
39
+ raise AcquisitionError("unsafe Windows launcher argument")
40
+ command = f'"{executable}" account get-access-token --resource "{resource}" --output json'
41
+ return [os.environ.get("COMSPEC", "cmd.exe"), "/d", "/s", "/c", command]
42
+ return [executable, *arguments]
43
+
44
+
45
+ class AzureCLI:
46
+ """
47
+ An Azure CLI executable used for noninteractive token acquisition.
48
+
49
+ Attributes:
50
+ executable: CLI name on PATH or an explicit executable path.
51
+ timeout: Maximum acquisition duration in seconds.
52
+ """
53
+
54
+ def __init__(self, executable: str = "az", timeout: float = 30) -> None:
55
+ self.executable = executable
56
+ self.timeout = timeout
57
+
58
+ async def _stop(self, process: asyncio.subprocess.Process) -> None:
59
+ if os.name == "nt":
60
+ if process.returncode is None:
61
+ killer = await asyncio.create_subprocess_exec(
62
+ "taskkill",
63
+ "/PID",
64
+ str(process.pid),
65
+ "/T",
66
+ "/F",
67
+ stdout=asyncio.subprocess.DEVNULL,
68
+ stderr=asyncio.subprocess.DEVNULL,
69
+ )
70
+ await killer.wait()
71
+ else:
72
+ # Descendants can retain the pipes after their CLI parent exits.
73
+ try:
74
+ os.killpg(process.pid, signal.SIGKILL)
75
+ except ProcessLookupError:
76
+ pass
77
+ await process.communicate()
78
+
79
+ async def acquire(self, resource: str) -> str:
80
+ """Return token metadata for a resource, or raise a sanitized acquisition error."""
81
+ executable = shutil.which(self.executable)
82
+ if executable is None:
83
+ raise AcquisitionError("Azure CLI executable not found")
84
+ command = cli_command(executable, resource, windows=os.name == "nt")
85
+ try:
86
+ if os.name == "nt" and executable.lower().endswith((".cmd", ".bat")):
87
+ # Only the strictly validated batch-launcher form uses cmd.
88
+ process = await asyncio.create_subprocess_shell(
89
+ command[-1],
90
+ executable=command[0],
91
+ stdout=asyncio.subprocess.PIPE,
92
+ stderr=asyncio.subprocess.PIPE,
93
+ )
94
+ else:
95
+ process = await asyncio.create_subprocess_exec(
96
+ *command,
97
+ stdout=asyncio.subprocess.PIPE,
98
+ stderr=asyncio.subprocess.PIPE,
99
+ start_new_session=os.name != "nt",
100
+ )
101
+ except OSError as error:
102
+ raise AcquisitionError("Azure CLI could not start") from error
103
+ try:
104
+ output, _ = await asyncio.wait_for(process.communicate(), self.timeout)
105
+ except TimeoutError as error:
106
+ await self._stop(process)
107
+ raise AcquisitionError("Azure CLI timed out") from error
108
+ except asyncio.CancelledError:
109
+ await self._stop(process)
110
+ raise
111
+ if process.returncode:
112
+ raise AcquisitionError("Azure CLI returned an error")
113
+ try:
114
+ return output.decode("utf-8")
115
+ except UnicodeDecodeError as error:
116
+ raise AcquisitionError("Azure CLI returned invalid text") from error
117
+
118
+
119
+ @dataclass
120
+ class CacheEntry:
121
+ token: str | None
122
+ expires: float
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class TokenResult:
127
+ token: str | None
128
+ source: Literal["acquired", "cached", "unavailable", "cooldown", "non-https"]
129
+
130
+
131
+ class TokenProvider:
132
+ """
133
+ A bounded, HTTPS-destination-isolated in-memory Azure token cache.
134
+
135
+ At most four acquisitions run concurrently across all origin/resource pairs.
136
+ Additional acquisitions wait; requests for the same pair share one acquisition.
137
+
138
+ Attributes:
139
+ refresh_margin: Seconds before expiry at which tokens stop being reused.
140
+ cooldown: Seconds to suppress repeated failed acquisitions per key.
141
+ capacity: Maximum number of cached origin/resource pairs.
142
+ closed: Whether shutdown has disabled further acquisition.
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ acquire: Callable[[str], Awaitable[str]],
148
+ *,
149
+ clock: Callable[[], float] = time.time,
150
+ refresh_margin: float = 120,
151
+ cooldown: float = 30,
152
+ capacity: int = 128,
153
+ ) -> None:
154
+ if capacity < 1:
155
+ raise ValueError("Token cache capacity must be positive")
156
+ self.acquire = acquire
157
+ self.clock = clock
158
+ self.refresh_margin = refresh_margin
159
+ self.cooldown = cooldown
160
+ self.capacity = capacity
161
+ self.entries: OrderedDict[tuple[str, str], CacheEntry] = OrderedDict()
162
+ self.pending: dict[tuple[str, str], asyncio.Task[str | None]] = {}
163
+ self._acquisitions = asyncio.Semaphore(4)
164
+ self.closed = False
165
+
166
+ async def get(self, origin: str, resource: str) -> str | None:
167
+ """Return a token, or None for non-HTTPS origins or acquisition failure."""
168
+ return (await self.get_with_status(origin, resource)).token
169
+
170
+ async def get_with_status(self, origin: str, resource: str) -> TokenResult:
171
+ """Return a token and its source; non-HTTPS destinations are always anonymous."""
172
+ if self.closed:
173
+ raise RuntimeError("Token provider is closed")
174
+ if urlsplit(origin).scheme != "https":
175
+ return TokenResult(None, "non-https")
176
+ key = (origin, resource)
177
+ entry = self.entries.get(key)
178
+ if entry is not None and self.clock() < entry.expires:
179
+ self.entries.move_to_end(key)
180
+ return TokenResult(
181
+ entry.token, "cached" if entry.token is not None else "cooldown"
182
+ )
183
+ task = self.pending.get(key)
184
+ if task is None:
185
+ task = asyncio.create_task(self._fetch(key))
186
+ self.pending[key] = task
187
+ token = await asyncio.shield(task)
188
+ return TokenResult(token, "acquired" if token is not None else "unavailable")
189
+
190
+ async def _fetch(self, key: tuple[str, str]) -> str | None:
191
+ origin, resource = key
192
+ try:
193
+ try:
194
+ async with self._acquisitions:
195
+ output = await self.acquire(resource)
196
+ try:
197
+ metadata = json.loads(output)
198
+ except (ValueError, TypeError) as error:
199
+ raise AcquisitionError("invalid token JSON") from error
200
+ if not isinstance(metadata, dict):
201
+ raise AcquisitionError("invalid token metadata")
202
+ token = metadata.get("accessToken")
203
+ expires = metadata.get("expires_on")
204
+ if (
205
+ not isinstance(token, str)
206
+ or not re.fullmatch(r"[A-Za-z0-9._~+/-]+=*", token)
207
+ or isinstance(expires, bool)
208
+ or not isinstance(expires, (str, int, float))
209
+ ):
210
+ raise AcquisitionError("missing or invalid token fields")
211
+ try:
212
+ usable_until = float(expires) - self.refresh_margin
213
+ except (ValueError, OverflowError) as error:
214
+ raise AcquisitionError("invalid token expiry") from error
215
+ if not math.isfinite(usable_until) or usable_until <= self.clock():
216
+ raise AcquisitionError("token is expired or too close to expiry")
217
+ entry = CacheEntry(token, usable_until)
218
+ except AcquisitionError as error:
219
+ LOG.warning(
220
+ "Token unavailable for %s (%s); forwarding anonymously for %.0f seconds",
221
+ origin,
222
+ error,
223
+ self.cooldown,
224
+ )
225
+ entry = CacheEntry(None, self.clock() + self.cooldown)
226
+ self.entries[key] = entry
227
+ self.entries.move_to_end(key)
228
+ while len(self.entries) > self.capacity:
229
+ self.entries.popitem(last=False)
230
+ return entry.token
231
+ finally:
232
+ self.pending.pop(key, None)
233
+
234
+ async def close(self) -> None:
235
+ """Cancel owned acquisitions and discard all cached tokens."""
236
+ self.closed = True
237
+ tasks = list(self.pending.values())
238
+ for task in tasks:
239
+ task.cancel()
240
+ await asyncio.gather(*tasks, return_exceptions=True)
241
+ self.pending.clear()
242
+ self.entries.clear()
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.5
2
+ Name: azure-dev-proxy
3
+ Version: 0.1.0
4
+ Summary: Loopback HTTP(S) proxy with HTTPS-only Azure CLI authentication
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: aiohttp<4,>=3.14
8
+ Requires-Dist: idna<4,>=3.7
@@ -0,0 +1,24 @@
1
+ azure_dev_proxy/__init__.py,sha256=PxTu1JdDx75QzJXlDfmckbebL8Ds4Fd_oglIpFGHI0Q,53
2
+ azure_dev_proxy/__main__.py,sha256=9dAb9GY0QwamOBHUBLA_FuM_EcC4e4LR-KnJEi9fPtw,3390
3
+ azure_dev_proxy/auth.py,sha256=CYh7fzvUz-WD76JtLXlIJl1abFupzesIe8YYAM7AJFQ,15355
4
+ azure_dev_proxy/config.py,sha256=r781cNOi2tjicOP4vPirszx4mn9oSZQphY_Fgk1jvE0,13726
5
+ azure_dev_proxy/default-config.json,sha256=t5Q5DT-2fW5_CrPwhyHL9CRObRYvnSNCck83LN_Acg0,2177
6
+ azure_dev_proxy/idle_timer.py,sha256=hLdlFIzz3sLiKBE2aUUd5vwBgxhg8W3FsWW4tnHLhCM,2013
7
+ azure_dev_proxy/listeners.py,sha256=I9Y0pRbey_MaNpIKHmCAv8BOhlH7W9SZg_WzamRM58k,4434
8
+ azure_dev_proxy/proxy.py,sha256=DsaJWfL4KZYCzEL355n5hmesxhvqycrR_WqyOx6G9_E,20552
9
+ azure_dev_proxy/tokens.py,sha256=Xxcw-UlvCU8WSgHauFeNczKKiARG-sVG5td2kjtETFU,9420
10
+ azure_dev_proxy/routers/__init__.py,sha256=cWzsfKVVRvjsea2Kga1U80AUMc-_zaRiBZ4kOMm8Ndg,107
11
+ azure_dev_proxy/routers/_base.py,sha256=B2O4N-6GWedDI3zPcFYv1ZTC4hHuKDpcc82AEjunowA,808
12
+ azure_dev_proxy/routers/graphiql.py,sha256=SH3AGzNdNF1q03Ep4JjgWFme57TzpSrB774tVOwNXWI,3591
13
+ azure_dev_proxy/routers/management.py,sha256=Njm1E8RsoFM4aIBTO-zm4QGvkl3oBpcznLSgaDToE8A,10002
14
+ azure_dev_proxy/routers/reverse_proxy.py,sha256=srWFkvMsgBi9-2z4ieJzHE72kBr7LLLN5gm4V_wLpr0,7227
15
+ azure_dev_proxy/routers/utils.py,sha256=_IQ4q4lqoqckHKVAYDZLXUn1-wIpkUvqr7m0MuARhCw,6942
16
+ azure_dev_proxy/static/app.js,sha256=l2LPl-o43eopo5PZqOBwjmm71vzbaiqyfxavBL-WYNo,4698
17
+ azure_dev_proxy/static/graphiql.html,sha256=nR3qnIJeYO4HKcRQBV5RD6TZ3tbMd0OJBvNPhBcFPCg,5141
18
+ azure_dev_proxy/static/index.html,sha256=3bL5LLv2FFh_ljEmGhyLOf20MEz2bcsP6j1A4Z9be9Q,6326
19
+ azure_dev_proxy/static/login.html,sha256=n2LKxwDUYh4_OlOAqdb7jW04F2FYVGn8DcPRtU59j94,962
20
+ azure_dev_proxy-0.1.0.dist-info/METADATA,sha256=9TL7VDeAKVzSiKf6MrOgpj_NEusP6YNJYcYpg5Ww9Do,238
21
+ azure_dev_proxy-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
22
+ azure_dev_proxy-0.1.0.dist-info/entry_points.txt,sha256=TEjJrRdc8dq_puC9u9GAl47m9iTvnxJU9y0FB75fpHQ,66
23
+ azure_dev_proxy-0.1.0.dist-info/licenses/LICENSE,sha256=HVA0vo3ifI_RAna5gohdhu7OgZsXJTz5YMl184juQz4,1072
24
+ azure_dev_proxy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ azure-dev-proxy = azure_dev_proxy.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Wardrop
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.