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 @@
|
|
|
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()
|
azure_dev_proxy/auth.py
ADDED
|
@@ -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
|