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 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)