masks-client 0.2.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.
- masks/__init__.py +116 -0
- masks/asgi.py +104 -0
- masks/auth.py +89 -0
- masks/client.py +1077 -0
- masks/django/__init__.py +171 -0
- masks/django/middleware.py +47 -0
- masks/django/urls.py +66 -0
- masks/dpop.py +56 -0
- masks/errors.py +80 -0
- masks/fastapi.py +248 -0
- masks/flask.py +226 -0
- masks/flows.py +282 -0
- masks/handshake.py +157 -0
- masks/keys.py +99 -0
- masks/keyset.py +113 -0
- masks/models.py +328 -0
- masks/oidc.py +131 -0
- masks/py.typed +0 -0
- masks/replay.py +37 -0
- masks/sources.py +54 -0
- masks/verifier.py +308 -0
- masks/web/__init__.py +36 -0
- masks/web/config.py +98 -0
- masks/web/core.py +1421 -0
- masks/web/credentials.py +128 -0
- masks/web/person.py +239 -0
- masks/web/sessions.py +391 -0
- masks_client-0.2.0.dist-info/METADATA +316 -0
- masks_client-0.2.0.dist-info/RECORD +31 -0
- masks_client-0.2.0.dist-info/WHEEL +4 -0
- masks_client-0.2.0.dist-info/licenses/LICENSE +21 -0
masks/__init__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from .auth import Auth
|
|
2
|
+
from .client import AsyncClient, Client
|
|
3
|
+
from .dpop import DPOP, proof
|
|
4
|
+
from .errors import (
|
|
5
|
+
DelegationRefused,
|
|
6
|
+
DelegationUnavailable,
|
|
7
|
+
Error,
|
|
8
|
+
ForeignOrigin,
|
|
9
|
+
InsecureURL,
|
|
10
|
+
InvalidToken,
|
|
11
|
+
MasksError,
|
|
12
|
+
Unavailable,
|
|
13
|
+
has_code,
|
|
14
|
+
)
|
|
15
|
+
from .flows import adiscover, adiscover_resource, discover, discover_resource
|
|
16
|
+
from .handshake import Handshake, HandshakeRequest
|
|
17
|
+
from .keys import Key
|
|
18
|
+
from .models import (
|
|
19
|
+
ACCESS_TOKEN_TYPE,
|
|
20
|
+
CLIENT_SECRET_BASIC,
|
|
21
|
+
CLIENT_SECRET_POST,
|
|
22
|
+
GRANT_CLIENT_CREDENTIALS,
|
|
23
|
+
GRANT_DEVICE_CODE,
|
|
24
|
+
GRANT_REFRESH_TOKEN,
|
|
25
|
+
GRANT_TOKEN_EXCHANGE,
|
|
26
|
+
ID_TOKEN_TYPE,
|
|
27
|
+
NO_AUTH,
|
|
28
|
+
PRIVATE_KEY_JWT,
|
|
29
|
+
UPSTREAM_ACCESS_TOKEN_TYPE,
|
|
30
|
+
AuthMethod,
|
|
31
|
+
ClientMetadata,
|
|
32
|
+
DeviceAuthorization,
|
|
33
|
+
Enrollment,
|
|
34
|
+
Introspection,
|
|
35
|
+
Issuer,
|
|
36
|
+
Metadata,
|
|
37
|
+
Registration,
|
|
38
|
+
ResourceMetadata,
|
|
39
|
+
Tenant,
|
|
40
|
+
Token,
|
|
41
|
+
)
|
|
42
|
+
from .oidc import (
|
|
43
|
+
DEFAULT_SCOPE,
|
|
44
|
+
AuthorizationRequest,
|
|
45
|
+
HeldConnection,
|
|
46
|
+
Logout,
|
|
47
|
+
SignIn,
|
|
48
|
+
Upstream,
|
|
49
|
+
organization_key,
|
|
50
|
+
)
|
|
51
|
+
from .replay import AsyncReplayStore, MemoryReplayStore, ReplayStore
|
|
52
|
+
from .sources import AsyncCachedTokenSource, AsyncTokenSource, CachedTokenSource, TokenSource
|
|
53
|
+
from .verifier import AsyncVerifier, Claims, Verifier
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"ACCESS_TOKEN_TYPE",
|
|
57
|
+
"CLIENT_SECRET_BASIC",
|
|
58
|
+
"CLIENT_SECRET_POST",
|
|
59
|
+
"DEFAULT_SCOPE",
|
|
60
|
+
"DPOP",
|
|
61
|
+
"GRANT_CLIENT_CREDENTIALS",
|
|
62
|
+
"GRANT_DEVICE_CODE",
|
|
63
|
+
"GRANT_REFRESH_TOKEN",
|
|
64
|
+
"GRANT_TOKEN_EXCHANGE",
|
|
65
|
+
"ID_TOKEN_TYPE",
|
|
66
|
+
"NO_AUTH",
|
|
67
|
+
"PRIVATE_KEY_JWT",
|
|
68
|
+
"UPSTREAM_ACCESS_TOKEN_TYPE",
|
|
69
|
+
"AsyncCachedTokenSource",
|
|
70
|
+
"AsyncClient",
|
|
71
|
+
"AsyncReplayStore",
|
|
72
|
+
"AsyncTokenSource",
|
|
73
|
+
"AsyncVerifier",
|
|
74
|
+
"Auth",
|
|
75
|
+
"AuthMethod",
|
|
76
|
+
"AuthorizationRequest",
|
|
77
|
+
"CachedTokenSource",
|
|
78
|
+
"Claims",
|
|
79
|
+
"Client",
|
|
80
|
+
"ClientMetadata",
|
|
81
|
+
"DelegationRefused",
|
|
82
|
+
"DelegationUnavailable",
|
|
83
|
+
"DeviceAuthorization",
|
|
84
|
+
"Enrollment",
|
|
85
|
+
"Error",
|
|
86
|
+
"ForeignOrigin",
|
|
87
|
+
"Handshake",
|
|
88
|
+
"HandshakeRequest",
|
|
89
|
+
"HeldConnection",
|
|
90
|
+
"InsecureURL",
|
|
91
|
+
"Introspection",
|
|
92
|
+
"InvalidToken",
|
|
93
|
+
"Issuer",
|
|
94
|
+
"Key",
|
|
95
|
+
"Logout",
|
|
96
|
+
"MasksError",
|
|
97
|
+
"MemoryReplayStore",
|
|
98
|
+
"Metadata",
|
|
99
|
+
"Registration",
|
|
100
|
+
"ReplayStore",
|
|
101
|
+
"ResourceMetadata",
|
|
102
|
+
"SignIn",
|
|
103
|
+
"Tenant",
|
|
104
|
+
"Token",
|
|
105
|
+
"TokenSource",
|
|
106
|
+
"Unavailable",
|
|
107
|
+
"Upstream",
|
|
108
|
+
"Verifier",
|
|
109
|
+
"adiscover",
|
|
110
|
+
"adiscover_resource",
|
|
111
|
+
"discover",
|
|
112
|
+
"discover_resource",
|
|
113
|
+
"has_code",
|
|
114
|
+
"organization_key",
|
|
115
|
+
"proof",
|
|
116
|
+
]
|
masks/asgi.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Awaitable, Callable, MutableMapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .dpop import DPOP
|
|
8
|
+
from .errors import InvalidToken, Unavailable
|
|
9
|
+
from .verifier import PROOF_ALGORITHMS, AsyncVerifier
|
|
10
|
+
|
|
11
|
+
Scope = MutableMapping[str, Any]
|
|
12
|
+
Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
|
|
13
|
+
Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
|
|
14
|
+
App = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
15
|
+
|
|
16
|
+
CLAIMS = "masks.claims"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def challenges(description: str) -> list[tuple[bytes, bytes]]:
|
|
20
|
+
quoted = description.replace("\\", "\\\\").replace('"', '\\"')
|
|
21
|
+
challenge = f'error="invalid_token", error_description="{quoted}"'
|
|
22
|
+
|
|
23
|
+
return [
|
|
24
|
+
(b"www-authenticate", f"Bearer {challenge}".encode()),
|
|
25
|
+
(b"www-authenticate", f'{DPOP} {challenge}, algs="{" ".join(PROOF_ALGORITHMS)}"'.encode()),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def requested_url(scope: Scope) -> str:
|
|
30
|
+
headers = dict(scope.get("headers") or [])
|
|
31
|
+
host = headers.get(b"host", b"").decode("latin-1")
|
|
32
|
+
|
|
33
|
+
if not host and scope.get("server"):
|
|
34
|
+
server_host, server_port = scope["server"]
|
|
35
|
+
host = f"{server_host}:{server_port}"
|
|
36
|
+
|
|
37
|
+
scheme = {"ws": "http", "wss": "https"}.get(scope.get("scheme", "http"), scope.get("scheme", "http"))
|
|
38
|
+
path = scope.get("raw_path") or scope.get("path", "/").encode()
|
|
39
|
+
query = scope.get("query_string") or b""
|
|
40
|
+
target = f"{scheme}://{host}{path.decode('latin-1')}"
|
|
41
|
+
|
|
42
|
+
return f"{target}?{query.decode('latin-1')}" if query else target
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Middleware:
|
|
46
|
+
def __init__(self, app: App, verifier: AsyncVerifier) -> None:
|
|
47
|
+
self.app = app
|
|
48
|
+
self.verifier = verifier
|
|
49
|
+
|
|
50
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
51
|
+
if scope["type"] not in ("http", "websocket"):
|
|
52
|
+
await self.app(scope, receive, send)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
headers = [(name.decode("latin-1"), value.decode("latin-1")) for name, value in scope.get("headers") or []]
|
|
56
|
+
method = scope.get("method", "GET")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
claims = await self.verifier.authenticate(method, requested_url(scope), headers)
|
|
60
|
+
except InvalidToken as error:
|
|
61
|
+
await self._refuse(scope, send, str(error).removeprefix("masks: "))
|
|
62
|
+
return
|
|
63
|
+
except Unavailable:
|
|
64
|
+
await self._unavailable(scope, send)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
scope[CLAIMS] = claims
|
|
68
|
+
scope.setdefault("state", {})["masks_claims"] = claims
|
|
69
|
+
|
|
70
|
+
await self.app(scope, receive, send)
|
|
71
|
+
|
|
72
|
+
async def _unavailable(self, scope: Scope, send: Send) -> None:
|
|
73
|
+
if scope["type"] == "websocket":
|
|
74
|
+
await send({"type": "websocket.close", "code": 1013})
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
body = json.dumps(
|
|
78
|
+
{"error": "temporarily_unavailable", "error_description": "the token could not be checked just now"}
|
|
79
|
+
).encode()
|
|
80
|
+
|
|
81
|
+
await send(
|
|
82
|
+
{
|
|
83
|
+
"type": "http.response.start",
|
|
84
|
+
"status": 503,
|
|
85
|
+
"headers": [(b"content-type", b"application/json"), (b"retry-after", b"5")],
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
await send({"type": "http.response.body", "body": body})
|
|
89
|
+
|
|
90
|
+
async def _refuse(self, scope: Scope, send: Send, description: str) -> None:
|
|
91
|
+
if scope["type"] == "websocket":
|
|
92
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
body = json.dumps({"error": "invalid_token", "error_description": description}).encode()
|
|
96
|
+
|
|
97
|
+
await send(
|
|
98
|
+
{
|
|
99
|
+
"type": "http.response.start",
|
|
100
|
+
"status": 401,
|
|
101
|
+
"headers": [(b"content-type", b"application/json"), *challenges(description)],
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
await send({"type": "http.response.body", "body": body})
|
masks/auth.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import AsyncGenerator, Generator
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .dpop import DPOP, proof
|
|
10
|
+
from .errors import ForeignOrigin
|
|
11
|
+
from .flows import origin
|
|
12
|
+
from .keys import Key
|
|
13
|
+
from .models import Token
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Auth(httpx.Auth):
|
|
17
|
+
requires_request_body = True
|
|
18
|
+
|
|
19
|
+
def __init__(self, source: Any, *origins: str, key: Key | None = None) -> None:
|
|
20
|
+
self.source = source
|
|
21
|
+
self.key = key
|
|
22
|
+
self.origins = frozenset(origin(held) for held in origins)
|
|
23
|
+
self._nonces: dict[str, str] = {}
|
|
24
|
+
|
|
25
|
+
def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
|
26
|
+
self._allowed(request)
|
|
27
|
+
|
|
28
|
+
if inspect.iscoroutinefunction(self.source.token):
|
|
29
|
+
raise TypeError("masks: an httpx.Client needs a token source that is not async")
|
|
30
|
+
|
|
31
|
+
yield from self._signed(request, self.source.token())
|
|
32
|
+
|
|
33
|
+
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
34
|
+
self._allowed(request)
|
|
35
|
+
|
|
36
|
+
token = self.source.token()
|
|
37
|
+
|
|
38
|
+
if inspect.isawaitable(token):
|
|
39
|
+
token = await token
|
|
40
|
+
|
|
41
|
+
flow = self._signed(request, token)
|
|
42
|
+
outgoing = next(flow)
|
|
43
|
+
|
|
44
|
+
while True:
|
|
45
|
+
response = yield outgoing
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
outgoing = flow.send(response)
|
|
49
|
+
except StopIteration:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
def _allowed(self, request: httpx.Request) -> None:
|
|
53
|
+
if origin(request.url) not in self.origins:
|
|
54
|
+
named = ", ".join(sorted(self.origins)) or "no origin"
|
|
55
|
+
raise ForeignOrigin(f"masks: this auth holds tokens only for {named}, not {origin(request.url)}")
|
|
56
|
+
|
|
57
|
+
def _signed(self, request: httpx.Request, token: Token) -> Generator[httpx.Request, httpx.Response, None]:
|
|
58
|
+
held = origin(request.url)
|
|
59
|
+
self._sign(request, token, self._nonces.get(held, ""))
|
|
60
|
+
|
|
61
|
+
response = yield request
|
|
62
|
+
|
|
63
|
+
if not token.bound:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
wanted = response.headers.get("DPoP-Nonce", "")
|
|
67
|
+
|
|
68
|
+
if not wanted:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
self._nonces[held] = wanted
|
|
72
|
+
|
|
73
|
+
if response.status_code != 401 or "use_dpop_nonce" not in response.headers.get("WWW-Authenticate", ""):
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
self._sign(request, token, wanted)
|
|
77
|
+
|
|
78
|
+
yield request
|
|
79
|
+
|
|
80
|
+
def _sign(self, request: httpx.Request, token: Token, nonce: str) -> None:
|
|
81
|
+
if not token.bound:
|
|
82
|
+
request.headers["Authorization"] = f"Bearer {token.access_token}"
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if self.key is None:
|
|
86
|
+
raise ValueError("masks: a DPoP token needs the key it is held to")
|
|
87
|
+
|
|
88
|
+
request.headers["Authorization"] = f"{DPOP} {token.access_token}"
|
|
89
|
+
request.headers["DPoP"] = proof(self.key, request.method, str(request.url), token.access_token, nonce)
|