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.
- azure_dev_proxy/__init__.py +1 -0
- azure_dev_proxy/__main__.py +104 -0
- azure_dev_proxy/auth.py +382 -0
- azure_dev_proxy/config.py +367 -0
- azure_dev_proxy/default-config.json +34 -0
- azure_dev_proxy/idle_timer.py +62 -0
- azure_dev_proxy/listeners.py +108 -0
- azure_dev_proxy/proxy.py +478 -0
- azure_dev_proxy/routers/__init__.py +3 -0
- azure_dev_proxy/routers/_base.py +31 -0
- azure_dev_proxy/routers/graphiql.py +96 -0
- azure_dev_proxy/routers/management.py +247 -0
- azure_dev_proxy/routers/reverse_proxy.py +174 -0
- azure_dev_proxy/routers/utils.py +220 -0
- azure_dev_proxy/static/app.js +124 -0
- azure_dev_proxy/static/graphiql.html +109 -0
- azure_dev_proxy/static/index.html +90 -0
- azure_dev_proxy/static/login.html +26 -0
- azure_dev_proxy/tokens.py +242 -0
- azure_dev_proxy-0.1.0.dist-info/METADATA +8 -0
- azure_dev_proxy-0.1.0.dist-info/RECORD +24 -0
- azure_dev_proxy-0.1.0.dist-info/WHEEL +4 -0
- azure_dev_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- azure_dev_proxy-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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,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.
|