quart-httpauth 0.0.1__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,643 @@
1
+ """Basic, Digest and Token HTTP authentication for Quart routes.
2
+
3
+ ``quart_httpauth`` is an async-native port of Flask-HTTPAuth_. The public API —
4
+ class names, constructor arguments, decorator names, and callback contracts — is
5
+ preserved, so existing Flask-HTTPAuth code transfers by switching the ``flask``
6
+ imports to ``quart`` and the ``flask_httpauth`` import to ``quart_httpauth``. The
7
+ only intentional behavioural change is that the request pipeline and every user
8
+ callback are awaited; callbacks may be written as either ``def`` or
9
+ ``async def``.
10
+
11
+ .. _Flask-HTTPAuth: https://github.com/miguelgrinberg/flask-httpauth
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hmac
17
+ from base64 import b64decode
18
+ from collections.abc import Awaitable
19
+ from functools import wraps
20
+ from hashlib import md5
21
+ from importlib.metadata import PackageNotFoundError, version
22
+ from random import Random, SystemRandom
23
+ from typing import Any, Callable, cast
24
+
25
+ from quart import Response, current_app, g, make_response, request, session
26
+ from werkzeug.datastructures import Authorization, Headers
27
+
28
+ __all__ = [
29
+ "HTTPAuth",
30
+ "HTTPBasicAuth",
31
+ "HTTPDigestAuth",
32
+ "HTTPTokenAuth",
33
+ "HTTPMultiAuth",
34
+ "MultiAuth",
35
+ ]
36
+
37
+ try:
38
+ __version__ = version("quart-httpauth")
39
+ except PackageNotFoundError: # pragma: no cover - running from an uninstalled tree
40
+ __version__ = "0.0.0"
41
+
42
+ #: A user callback, which may be synchronous or a coroutine function.
43
+ Callback = Callable[..., Any]
44
+
45
+
46
+ class HTTPAuth:
47
+ """Base class for the HTTP authentication schemes.
48
+
49
+ This class is effectively abstract: it owns the shared machinery (the
50
+ ``login_required`` decorator, error handling, header parsing, and role
51
+ authorization) but delegates credential checking to :meth:`authenticate`,
52
+ which each concrete subclass implements.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ scheme: str | None = None,
58
+ realm: str | None = None,
59
+ header: str | None = None,
60
+ ) -> None:
61
+ self.scheme = scheme
62
+ self.realm = realm or "Authentication Required"
63
+ self.header = header
64
+ self.get_password_callback: Callback | None = None
65
+ self.get_user_roles_callback: Callback | None = None
66
+ self.auth_error_callback: Callable[..., Awaitable[Response]]
67
+
68
+ def default_get_password(username: str) -> None:
69
+ return None
70
+
71
+ def default_auth_error(status: int) -> tuple[str, int]:
72
+ return "Unauthorized Access", status
73
+
74
+ self.get_password(default_get_password)
75
+ self.error_handler(default_auth_error)
76
+
77
+ def is_compatible_auth(self, headers: Headers) -> bool:
78
+ """Return whether *headers* carry credentials for this scheme."""
79
+ if self.header is None or self.header == "Authorization":
80
+ try:
81
+ scheme, _ = headers.get("Authorization", "").split(None, 1)
82
+ except ValueError:
83
+ # malformed or absent Authorization header
84
+ return False
85
+ return scheme == self.scheme
86
+ return self.header in headers
87
+
88
+ def get_password(self, f: Callback) -> Callback:
89
+ """Register the callback used to obtain a user's stored password.
90
+
91
+ This is the primary mechanism used by :class:`HTTPDigestAuth`; it is not
92
+ a credential path for Basic authentication (use
93
+ :meth:`HTTPBasicAuth.verify_password` instead). Example::
94
+
95
+ @auth.get_password
96
+ def get_password(username):
97
+ return db.get_user_password(username)
98
+ """
99
+ self.get_password_callback = f
100
+ return f
101
+
102
+ def get_user_roles(self, f: Callback) -> Callback:
103
+ """Register the callback that returns the roles assigned to a user.
104
+
105
+ The callback receives the user returned by the verify callback (or, when
106
+ that callback returned ``True``, the ``Authorization`` object) and
107
+ returns a role or list of roles. Example::
108
+
109
+ @auth.get_user_roles
110
+ def get_user_roles(user):
111
+ return user.get_roles()
112
+ """
113
+ self.get_user_roles_callback = f
114
+ return f
115
+
116
+ def error_handler(self, f: Callback) -> Callable[..., Awaitable[Response]]:
117
+ """Register the callback that builds authentication error responses.
118
+
119
+ The callback may take one argument (the status code, ``401`` or ``403``)
120
+ or, for backward compatibility, no arguments. Its return value may be
121
+ any response type accepted by Quart routes; the framework guarantees a
122
+ ``WWW-Authenticate`` header is present. Example::
123
+
124
+ @auth.error_handler
125
+ def auth_error(status):
126
+ return "Access Denied", status
127
+ """
128
+
129
+ @wraps(f)
130
+ async def decorated(*args: Any, **kwargs: Any) -> Response:
131
+ result = await self.ensure_async(f)(*args, **kwargs)
132
+ explicit_status = isinstance(result, (tuple, Response))
133
+ response = cast(Response, await make_response(result))
134
+ if not explicit_status and response.status_code == 200:
135
+ # the callback did not set a status code, so default to 401
136
+ response.status_code = 401
137
+ if "WWW-Authenticate" not in response.headers:
138
+ response.headers["WWW-Authenticate"] = await self.authenticate_header()
139
+ return response
140
+
141
+ self.auth_error_callback = decorated
142
+ return decorated
143
+
144
+ async def authenticate_header(self) -> str:
145
+ """Return the value of the ``WWW-Authenticate`` challenge header."""
146
+ return f'{self.scheme} realm="{self.realm}"'
147
+
148
+ def get_auth(self) -> Authorization | None:
149
+ """Parse the request's credentials into an ``Authorization`` object."""
150
+ auth: Authorization | None = None
151
+ if self.header is None or self.header == "Authorization":
152
+ auth = request.authorization
153
+ # Werkzeug < 2.3 only recognizes Basic and Digest, so on older
154
+ # versions the header is parsed by hand here.
155
+ if auth is None and "Authorization" in request.headers: # pragma: no cover
156
+ try:
157
+ auth_type, token = request.headers["Authorization"].split(None, 1)
158
+ auth = Authorization(auth_type)
159
+ auth.token = token
160
+ except (ValueError, KeyError):
161
+ pass
162
+ elif self.header in request.headers:
163
+ # a custom header is used, so its entire value is the token
164
+ auth = Authorization(self.scheme or "")
165
+ auth.token = request.headers[self.header]
166
+
167
+ # If the scheme does not match we behave as if there were no auth at
168
+ # all. This is friendlier than failing outright, as it lets callbacks
169
+ # handle special cases such as supporting multiple schemes.
170
+ if auth is not None and auth.type.lower() != (self.scheme or "").lower():
171
+ auth = None
172
+
173
+ return auth
174
+
175
+ async def get_auth_password(self, auth: Authorization | None) -> Any:
176
+ """Look up the stored password for the authenticated username."""
177
+ if auth and auth.username and self.get_password_callback is not None:
178
+ return await self.ensure_async(self.get_password_callback)(auth.username)
179
+ return None
180
+
181
+ async def authorize(
182
+ self,
183
+ role: Any,
184
+ user: Any,
185
+ auth: Authorization | None,
186
+ ) -> bool | None:
187
+ """Return whether *user* satisfies the required *role*."""
188
+ if role is None:
189
+ return True
190
+ roles = list(role) if isinstance(role, (list, tuple)) else [role]
191
+ if user is True:
192
+ user = auth
193
+ if self.get_user_roles_callback is None: # pragma: no cover
194
+ raise ValueError("get_user_roles callback is not defined")
195
+
196
+ user_roles = await self.ensure_async(self.get_user_roles_callback)(user)
197
+ if user_roles is None:
198
+ user_roles = set()
199
+ elif not isinstance(user_roles, (list, tuple)):
200
+ user_roles = {user_roles}
201
+ else:
202
+ user_roles = set(user_roles)
203
+
204
+ for required in roles:
205
+ if isinstance(required, (list, tuple)):
206
+ required = set(required)
207
+ if required & user_roles == required:
208
+ return True
209
+ elif required in user_roles:
210
+ return True
211
+ return None
212
+
213
+ async def authenticate(
214
+ self, auth: Authorization | None, stored_password: Any
215
+ ) -> Any:
216
+ """Verify the request's credentials (implemented by subclasses)."""
217
+ raise NotImplementedError # pragma: no cover
218
+
219
+ def login_required(
220
+ self,
221
+ f: Callback | None = None,
222
+ role: Any = None,
223
+ optional: bool | None = None,
224
+ ) -> Any:
225
+ """Protect a view so it only runs after successful authentication.
226
+
227
+ Example::
228
+
229
+ @app.route("/private")
230
+ @auth.login_required
231
+ async def private_page():
232
+ return "Only for authorized people!"
233
+
234
+ Pass ``role`` to require one or more roles, or ``optional=True`` to allow
235
+ anonymous access (in which case :meth:`current_user` returns ``None``).
236
+ """
237
+ if f is not None and (role is not None or optional is not None):
238
+ raise ValueError( # pragma: no cover
239
+ "role and optional are the only supported arguments"
240
+ )
241
+
242
+ def login_required_internal(f: Callback) -> Callback:
243
+ @wraps(f)
244
+ async def decorated(*args: Any, **kwargs: Any) -> Any:
245
+ auth = self.get_auth()
246
+
247
+ # Quart normally handles OPTIONS requests itself, but if it is
248
+ # configured to forward them we must ignore the authentication
249
+ # headers and let the request through, so CORS preflight is
250
+ # never challenged.
251
+ if request.method != "OPTIONS": # pragma: no cover
252
+ password = await self.get_auth_password(auth)
253
+
254
+ status = None
255
+ user = await self.authenticate(auth, password)
256
+ if user in (False, None):
257
+ status = 401
258
+ elif not await self.authorize(role, user, auth):
259
+ status = 403
260
+ if not optional and status:
261
+ try:
262
+ return await self.auth_error_callback(status)
263
+ except TypeError:
264
+ return await self.auth_error_callback()
265
+
266
+ if user is True:
267
+ g.quart_httpauth_user = auth.username if auth else None
268
+ else:
269
+ g.quart_httpauth_user = user
270
+ return await self.ensure_async(f)(*args, **kwargs)
271
+
272
+ return decorated
273
+
274
+ if f:
275
+ return login_required_internal(f)
276
+ return login_required_internal
277
+
278
+ def current_user(self) -> Any:
279
+ """Return the user resolved by the verify callback, or ``None``.
280
+
281
+ When the verify callback returned ``True`` instead of a user object,
282
+ this is the username (or token) sent by the client. Example::
283
+
284
+ @app.route("/")
285
+ @auth.login_required
286
+ async def index():
287
+ return f"Hello, {auth.current_user()}!"
288
+ """
289
+ return getattr(g, "quart_httpauth_user", None)
290
+
291
+ def ensure_async(self, f: Callback) -> Callable[..., Awaitable[Any]]:
292
+ """Adapt *f* into an awaitable, whether it is sync or async.
293
+
294
+ Sync callbacks are offloaded to Quart's executor so they do not block
295
+ the event loop; coroutine functions are returned unchanged.
296
+ """
297
+ try:
298
+ return current_app.ensure_async(f)
299
+ except AttributeError: # pragma: no cover - very old Quart
300
+ return f
301
+
302
+
303
+ class HTTPBasicAuth(HTTPAuth):
304
+ """HTTP Basic authentication (RFC 7617).
305
+
306
+ If *scheme* is given it replaces the standard ``Basic`` scheme in the
307
+ ``WWW-Authenticate`` response; a custom scheme is a common way to stop
308
+ browsers from showing their built-in login prompt. *realm* sets the
309
+ application-defined realm advertised in that header.
310
+ """
311
+
312
+ def __init__(self, scheme: str | None = None, realm: str | None = None) -> None:
313
+ super().__init__(scheme or "Basic", realm)
314
+ self.verify_password_callback: Callback | None = None
315
+
316
+ def verify_password(self, f: Callback) -> Callback:
317
+ """Register the callback that verifies a username and password.
318
+
319
+ The callback receives the username and password and returns the user
320
+ object on success, ``True`` when there is no user object, or
321
+ ``None``/``False`` on failure. Example::
322
+
323
+ @auth.verify_password
324
+ async def verify_password(username, password):
325
+ user = await User.get(username)
326
+ if user and user.check_password(password):
327
+ return user
328
+
329
+ It is also called for requests without credentials, with both arguments
330
+ set to empty strings; returning ``True`` there allows anonymous access.
331
+ """
332
+ self.verify_password_callback = f
333
+ return f
334
+
335
+ def get_auth(self) -> Authorization | None:
336
+ # This parser is more permissive than Werkzeug's: it accepts schemes
337
+ # other than "Basic" and falls back to latin-1 decoding.
338
+ header = self.header or "Authorization"
339
+ if header not in request.headers:
340
+ return None
341
+ value = request.headers[header].encode("utf-8")
342
+ try:
343
+ scheme, credentials = value.split(b" ", 1)
344
+ encoded_username, encoded_password = b64decode(credentials).split(b":", 1)
345
+ except (ValueError, TypeError):
346
+ return None
347
+ try:
348
+ username = encoded_username.decode("utf-8")
349
+ password = encoded_password.decode("utf-8")
350
+ except UnicodeDecodeError:
351
+ # latin-1 always succeeds, so it is a safe fallback
352
+ username = encoded_username.decode("latin1")
353
+ password = encoded_password.decode("latin1")
354
+
355
+ return Authorization(
356
+ scheme.decode("ascii"),
357
+ {"username": username, "password": password},
358
+ )
359
+
360
+ async def authenticate(
361
+ self, auth: Authorization | None, stored_password: Any
362
+ ) -> Any:
363
+ if auth:
364
+ username = auth.username
365
+ client_password = auth.password
366
+ else:
367
+ username = ""
368
+ client_password = ""
369
+ if self.verify_password_callback is not None:
370
+ return await self.ensure_async(self.verify_password_callback)(
371
+ username, client_password
372
+ )
373
+ return None
374
+
375
+
376
+ class HTTPDigestAuth(HTTPAuth):
377
+ """HTTP Digest authentication (RFC 7616).
378
+
379
+ If *scheme* is given it replaces the standard ``Digest`` scheme in the
380
+ challenge, and *realm* sets the advertised realm.
381
+
382
+ When *use_ha1_pw* is ``False`` the :meth:`get_password` callback returns the
383
+ plaintext password; when it is ``True`` the callback returns the HA1 hash,
384
+ allowing the application to avoid storing plaintext passwords.
385
+
386
+ *qop* configures the accepted quality-of-protection values (a comma-separated
387
+ string, a list, or ``None`` to disable); the default is ``auth`` and
388
+ ``auth-int`` is not implemented. *algorithm* selects ``MD5`` (default) or
389
+ ``MD5-Sess``.
390
+
391
+ .. note::
392
+ By default the ``nonce`` and ``opaque`` values are stored in the Quart
393
+ session. With the default cookie-based sessions these values can leak,
394
+ so a server-side session backend should be used in production.
395
+ """
396
+
397
+ def __init__(
398
+ self,
399
+ scheme: str | None = None,
400
+ realm: str | None = None,
401
+ use_ha1_pw: bool = False,
402
+ qop: str | list[str] | None = "auth",
403
+ algorithm: str = "MD5",
404
+ ) -> None:
405
+ super().__init__(scheme or "Digest", realm)
406
+ self.use_ha1_pw = use_ha1_pw
407
+ if isinstance(qop, str):
408
+ self.qop = [v.strip() for v in qop.split(",")]
409
+ else:
410
+ self.qop = qop or []
411
+ if algorithm.lower() == "md5":
412
+ self.algorithm = "MD5"
413
+ elif algorithm.lower() == "md5-sess":
414
+ self.algorithm = "MD5-Sess"
415
+ else:
416
+ raise ValueError(f"Algorithm {algorithm} is not supported")
417
+
418
+ self.random: SystemRandom | Random = SystemRandom()
419
+ try:
420
+ self.random.random()
421
+ except NotImplementedError: # pragma: no cover
422
+ self.random = Random()
423
+
424
+ self.generate_nonce_callback: Callback
425
+ self.verify_nonce_callback: Callback
426
+ self.generate_opaque_callback: Callback
427
+ self.verify_opaque_callback: Callback
428
+
429
+ def generate_random() -> str:
430
+ return md5(str(self.random.random()).encode("utf-8")).hexdigest()
431
+
432
+ def default_generate_nonce() -> str:
433
+ session["auth_nonce"] = generate_random()
434
+ return session["auth_nonce"]
435
+
436
+ def default_verify_nonce(nonce: str | None) -> bool:
437
+ session_nonce = session.get("auth_nonce")
438
+ if nonce is None or session_nonce is None:
439
+ return False
440
+ return hmac.compare_digest(nonce, session_nonce)
441
+
442
+ def default_generate_opaque() -> str:
443
+ session["auth_opaque"] = generate_random()
444
+ return session["auth_opaque"]
445
+
446
+ def default_verify_opaque(opaque: str | None) -> bool:
447
+ session_opaque = session.get("auth_opaque")
448
+ if opaque is None or session_opaque is None: # pragma: no cover
449
+ return False
450
+ return hmac.compare_digest(opaque, session_opaque)
451
+
452
+ self.generate_nonce(default_generate_nonce)
453
+ self.generate_opaque(default_generate_opaque)
454
+ self.verify_nonce(default_verify_nonce)
455
+ self.verify_opaque(default_verify_opaque)
456
+
457
+ def generate_nonce(self, f: Callback) -> Callback:
458
+ """Register the callback that generates a nonce.
459
+
460
+ Defining this (together with :meth:`verify_nonce`) lets the application
461
+ use a state store other than the session.
462
+ """
463
+ self.generate_nonce_callback = f
464
+ return f
465
+
466
+ def verify_nonce(self, f: Callback) -> Callback:
467
+ """Register the callback that verifies a nonce."""
468
+ self.verify_nonce_callback = f
469
+ return f
470
+
471
+ def generate_opaque(self, f: Callback) -> Callback:
472
+ """Register the callback that generates an opaque value.
473
+
474
+ Defining this (together with :meth:`verify_opaque`) lets the application
475
+ use a state store other than the session.
476
+ """
477
+ self.generate_opaque_callback = f
478
+ return f
479
+
480
+ def verify_opaque(self, f: Callback) -> Callback:
481
+ """Register the callback that verifies an opaque value."""
482
+ self.verify_opaque_callback = f
483
+ return f
484
+
485
+ async def get_nonce(self) -> str:
486
+ return await self.ensure_async(self.generate_nonce_callback)()
487
+
488
+ async def get_opaque(self) -> str:
489
+ return await self.ensure_async(self.generate_opaque_callback)()
490
+
491
+ def generate_ha1(self, username: str, password: str) -> str:
492
+ """Return the HA1 hash to store when ``use_ha1_pw`` is ``True``."""
493
+ a1 = f"{username}:{self.realm}:{password}".encode()
494
+ return md5(a1).hexdigest()
495
+
496
+ async def authenticate_header(self) -> str:
497
+ nonce = await self.get_nonce()
498
+ opaque = await self.get_opaque()
499
+ challenge = (
500
+ f'{self.scheme} realm="{self.realm}",nonce="{nonce}",' f'opaque="{opaque}"'
501
+ )
502
+ if self.qop:
503
+ qop_str = ",".join(self.qop)
504
+ challenge += f',algorithm="{self.algorithm}",qop="{qop_str}"'
505
+ return challenge
506
+
507
+ async def authenticate(
508
+ self, auth: Authorization | None, stored_password_or_ha1: Any
509
+ ) -> bool:
510
+ if (
511
+ not auth
512
+ or not auth.username
513
+ or not auth.realm
514
+ or not auth.uri
515
+ or not auth.nonce
516
+ or not auth.response
517
+ or not stored_password_or_ha1
518
+ ):
519
+ return False
520
+
521
+ nonce_ok = await self.ensure_async(self.verify_nonce_callback)(auth.nonce)
522
+ opaque_ok = await self.ensure_async(self.verify_opaque_callback)(auth.opaque)
523
+ if not nonce_ok or not opaque_ok:
524
+ return False
525
+ if auth.qop and auth.qop not in self.qop: # pragma: no cover
526
+ return False
527
+
528
+ if self.use_ha1_pw:
529
+ ha1 = stored_password_or_ha1
530
+ else:
531
+ a1 = f"{auth.username}:{auth.realm}:{stored_password_or_ha1}"
532
+ ha1 = md5(a1.encode("utf-8")).hexdigest()
533
+ if self.algorithm == "MD5-Sess":
534
+ ha1 = md5(f"{ha1}:{auth.nonce}:{auth.cnonce}".encode()).hexdigest()
535
+
536
+ a2 = f"{request.method}:{auth.uri}"
537
+ ha2 = md5(a2.encode("utf-8")).hexdigest()
538
+ if auth.qop == "auth":
539
+ a3 = f"{ha1}:{auth.nonce}:{auth.nc}:{auth.cnonce}:auth:{ha2}"
540
+ else:
541
+ a3 = f"{ha1}:{auth.nonce}:{ha2}"
542
+ response = md5(a3.encode("utf-8")).hexdigest()
543
+ return hmac.compare_digest(response, auth.response)
544
+
545
+
546
+ class HTTPTokenAuth(HTTPAuth):
547
+ """Bearer or custom-header token authentication.
548
+
549
+ *scheme* names the scheme used in the ``Authorization`` header and the
550
+ challenge (``Bearer`` by default)::
551
+
552
+ Authorization: Bearer this-is-my-token
553
+
554
+ *realm* sets the advertised realm. *header* selects a custom header to read
555
+ the token from instead of ``Authorization``; there the whole header value is
556
+ the token and no scheme prefix is expected::
557
+
558
+ X-API-Key: this-is-my-token
559
+ """
560
+
561
+ def __init__(
562
+ self,
563
+ scheme: str = "Bearer",
564
+ realm: str | None = None,
565
+ header: str | None = None,
566
+ ) -> None:
567
+ super().__init__(scheme, realm, header)
568
+ self.verify_token_callback: Callback | None = None
569
+
570
+ def verify_token(self, f: Callback) -> Callback:
571
+ """Register the callback that verifies a token.
572
+
573
+ The callback receives the token and returns the user object on success,
574
+ ``True`` when there is no user object, or ``None``/``False`` on failure.
575
+ A verify callback is required when using this class. Example::
576
+
577
+ @auth.verify_token
578
+ async def verify_token(token):
579
+ return await User.get_by_token(token)
580
+ """
581
+ self.verify_token_callback = f
582
+ return f
583
+
584
+ async def authenticate(
585
+ self, auth: Authorization | None, stored_password: Any
586
+ ) -> Any:
587
+ token = getattr(auth, "token", None)
588
+ if token and self.verify_token_callback is not None:
589
+ return await self.ensure_async(self.verify_token_callback)(token)
590
+ return None
591
+
592
+
593
+ class MultiAuth:
594
+ """Try several authentication schemes in order on a single route.
595
+
596
+ Construct it with one or more of :class:`HTTPBasicAuth`,
597
+ :class:`HTTPDigestAuth` or :class:`HTTPTokenAuth`. A protected route uses the
598
+ first scheme whose header is compatible with the request, falling back to the
599
+ main scheme's challenge when none match.
600
+ """
601
+
602
+ def __init__(self, main_auth: HTTPAuth, *additional_auths: HTTPAuth) -> None:
603
+ self.main_auth = main_auth
604
+ self.additional_auths = additional_auths
605
+
606
+ def login_required(
607
+ self,
608
+ f: Callback | None = None,
609
+ role: Any = None,
610
+ optional: bool | None = None,
611
+ ) -> Any:
612
+ """Protect a view, delegating to the first compatible scheme."""
613
+ if f is not None and (role is not None or optional is not None):
614
+ raise ValueError( # pragma: no cover
615
+ "role and optional are the only supported arguments"
616
+ )
617
+
618
+ def login_required_internal(f: Callback) -> Callback:
619
+ @wraps(f)
620
+ async def decorated(*args: Any, **kwargs: Any) -> Any:
621
+ selected_auth = self.main_auth
622
+ if not self.main_auth.is_compatible_auth(request.headers):
623
+ for auth in self.additional_auths:
624
+ if auth.is_compatible_auth(request.headers):
625
+ selected_auth = auth
626
+ break
627
+ guarded = selected_auth.login_required(role=role, optional=optional)(f)
628
+ return await guarded(*args, **kwargs)
629
+
630
+ return decorated
631
+
632
+ if f:
633
+ return login_required_internal(f)
634
+ return login_required_internal
635
+
636
+ def current_user(self) -> Any:
637
+ """Return the authenticated user, or ``None``."""
638
+ return getattr(g, "quart_httpauth_user", None)
639
+
640
+
641
+ #: Friendlier alias. Upstream Flask-HTTPAuth exposes the class as ``MultiAuth``,
642
+ #: so both names are provided to maximise compatibility.
643
+ HTTPMultiAuth = MultiAuth
File without changes
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: quart-httpauth
3
+ Version: 0.0.1
4
+ Summary: Basic, Digest and Token HTTP authentication for Quart routes
5
+ Project-URL: Homepage, https://github.com/lymagics/quart_httpauth
6
+ Project-URL: Bug Tracker, https://github.com/lymagics/quart_httpauth/issues
7
+ Author: quart_httpauth contributors
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: async,authentication,basic,bearer,digest,http,quart,token
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
22
+ Classifier: Topic :: Security
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: quart>=0.19
25
+ Provides-Extra: dev
26
+ Requires-Dist: black; extra == 'dev'
27
+ Requires-Dist: coverage; extra == 'dev'
28
+ Requires-Dist: hypercorn; extra == 'dev'
29
+ Requires-Dist: hypothesis; extra == 'dev'
30
+ Requires-Dist: language-tool-python; extra == 'dev'
31
+ Requires-Dist: mypy; extra == 'dev'
32
+ Requires-Dist: pyhamcrest; extra == 'dev'
33
+ Requires-Dist: pytest; extra == 'dev'
34
+ Requires-Dist: pytest-asyncio; extra == 'dev'
35
+ Requires-Dist: pytest-cov; extra == 'dev'
36
+ Requires-Dist: pytest-fail-slow; extra == 'dev'
37
+ Requires-Dist: pytest-randomly; extra == 'dev'
38
+ Requires-Dist: pytest-repeat; extra == 'dev'
39
+ Requires-Dist: ruff; extra == 'dev'
40
+ Provides-Extra: docs
41
+ Requires-Dist: furo; extra == 'docs'
42
+ Requires-Dist: sphinx; extra == 'docs'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # quart-httpauth
46
+
47
+ Basic, Digest and Token (Bearer) HTTP authentication for [Quart](https://github.com/pallets/quart)
48
+ routes — an **async-native port of [Flask-HTTPAuth](https://github.com/miguelgrinberg/flask-httpauth)**.
49
+
50
+ The public API mirrors Flask-HTTPAuth: class names, constructor arguments,
51
+ decorator names, and callback contracts are preserved. Existing Flask-HTTPAuth
52
+ code transfers by switching `flask` imports to `quart` and `flask_httpauth` to
53
+ `quart_httpauth`. The one intentional change is that the request pipeline and
54
+ every callback are awaited — your verify/role/error callbacks may be written as
55
+ either `def` **or** `async def`.
56
+
57
+ ## Installation
58
+
59
+ ```
60
+ pip install quart-httpauth
61
+ ```
62
+
63
+ The distribution name is `quart-httpauth`; the import name is `quart_httpauth`.
64
+
65
+ ## Quickstart — Basic auth
66
+
67
+ ```python
68
+ from quart import Quart
69
+ from quart_httpauth import HTTPBasicAuth
70
+ from werkzeug.security import check_password_hash, generate_password_hash
71
+
72
+ app = Quart(__name__)
73
+ auth = HTTPBasicAuth()
74
+
75
+ users = {
76
+ "susan": generate_password_hash("hunter2"),
77
+ }
78
+
79
+
80
+ @auth.verify_password
81
+ async def verify_password(username, password):
82
+ if username in users and check_password_hash(users[username], password):
83
+ return username
84
+
85
+
86
+ @app.route("/")
87
+ @auth.login_required
88
+ async def index():
89
+ return f"Hello, {auth.current_user()}!"
90
+ ```
91
+
92
+ ## Token (Bearer) auth
93
+
94
+ ```python
95
+ from quart import Quart
96
+ from quart_httpauth import HTTPTokenAuth
97
+
98
+ app = Quart(__name__)
99
+ auth = HTTPTokenAuth(scheme="Bearer")
100
+
101
+ tokens = {"secret-token-1": "john"}
102
+
103
+
104
+ @auth.verify_token
105
+ async def verify_token(token):
106
+ return tokens.get(token)
107
+
108
+
109
+ @app.route("/")
110
+ @auth.login_required
111
+ async def index():
112
+ return f"Hello, {auth.current_user()}!"
113
+ ```
114
+
115
+ A custom header works too: `HTTPTokenAuth(header="X-API-Key")` — there the entire
116
+ header value is treated as the token with no scheme prefix.
117
+
118
+ ## Roles and optional auth
119
+
120
+ ```python
121
+ @auth.get_user_roles
122
+ async def get_user_roles(user):
123
+ return user.roles
124
+
125
+
126
+ @app.route("/admin")
127
+ @auth.login_required(role="admin")
128
+ async def admin():
129
+ return "for admins only"
130
+
131
+
132
+ @app.route("/maybe")
133
+ @auth.login_required(optional=True)
134
+ async def maybe():
135
+ user = auth.current_user()
136
+ return f"Hello, {user or 'anonymous'}!"
137
+ ```
138
+
139
+ ## Multiple schemes on one route
140
+
141
+ ```python
142
+ from quart_httpauth import HTTPBasicAuth, HTTPTokenAuth, MultiAuth
143
+
144
+ basic = HTTPBasicAuth()
145
+ token = HTTPTokenAuth()
146
+ multi = MultiAuth(basic, token) # HTTPMultiAuth is an alias
147
+
148
+
149
+ @app.route("/")
150
+ @multi.login_required
151
+ async def index():
152
+ return f"Hello, {multi.current_user()}!"
153
+ ```
154
+
155
+ See [`examples/`](examples/) for runnable apps.
156
+
157
+ ## Differences from Flask-HTTPAuth
158
+
159
+ - The deprecated `hash_password` callback, the `username()` accessor, and the
160
+ legacy `get_password`-as-Basic-credential path are **not** ported. Use
161
+ `verify_password` for Basic auth. `get_password` is retained for
162
+ `HTTPDigestAuth`, where it is the primary, non-deprecated mechanism.
163
+ - The per-request user is stored on `g.quart_httpauth_user`.
164
+
165
+ ## License
166
+
167
+ MIT. Ported from Flask-HTTPAuth (also MIT) by Miguel Grinberg.
@@ -0,0 +1,6 @@
1
+ quart_httpauth/__init__.py,sha256=4Y8c3_Pb9n0-I99jveeTmt3VwlfDWnX-vavwo_ev9iM,24863
2
+ quart_httpauth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ quart_httpauth-0.0.1.dist-info/METADATA,sha256=3cguDgakju7bZDZ2ZIDI6U13_5ilmojEoTzE9dEODnQ,4869
4
+ quart_httpauth-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ quart_httpauth-0.0.1.dist-info/licenses/LICENSE,sha256=EefOWiPKOOz3R1pJ5rnvxq0ATiIRSXNxwSlDzwNCw-M,1077
6
+ quart_httpauth-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Viacheslav Lymanskyi
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.