vigrise 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.
- vigrise/__init__.py +4 -0
- vigrise/_common/__init__.py +1 -0
- vigrise/_common/endpoint.py +38 -0
- vigrise/_common/envelope.py +30 -0
- vigrise/_common/http.py +69 -0
- vigrise/_common/multipart.py +29 -0
- vigrise/_common/pagination.py +11 -0
- vigrise/_common/senders.py +113 -0
- vigrise/accounts/__init__.py +39 -0
- vigrise/accounts/backchannel.py +69 -0
- vigrise/accounts/dto.py +131 -0
- vigrise/accounts/errors.py +40 -0
- vigrise/accounts/hash.py +9 -0
- vigrise/accounts/introspect.py +187 -0
- vigrise/accounts/jwks.py +121 -0
- vigrise/accounts/oauth2.py +273 -0
- vigrise/accounts/verify.py +38 -0
- vigrise/auth/__init__.py +16 -0
- vigrise/auth/canonical.py +68 -0
- vigrise/auth/credentials.py +82 -0
- vigrise/auth/endpoint.py +5 -0
- vigrise/auth/httpx_auth.py +28 -0
- vigrise/auth/requests_auth.py +29 -0
- vigrise/auth/signer.py +88 -0
- vigrise/auth/signing.py +28 -0
- vigrise/auth/sts.py +90 -0
- vigrise/speech/__init__.py +88 -0
- vigrise/speech/client.py +442 -0
- vigrise/speech/dto.py +226 -0
- vigrise/speech/enums.py +60 -0
- vigrise/speech/errors.py +58 -0
- vigrise/speech/operations.py +270 -0
- vigrise/speech/webhook.py +92 -0
- vigrise/storage/__init__.py +28 -0
- vigrise/storage/client.py +586 -0
- vigrise/storage/dto.py +137 -0
- vigrise/storage/errors.py +28 -0
- vigrise/storage/operations.py +342 -0
- vigrise-0.1.0.dist-info/METADATA +69 -0
- vigrise-0.1.0.dist-info/RECORD +42 -0
- vigrise-0.1.0.dist-info/WHEEL +4 -0
- vigrise-0.1.0.dist-info/licenses/LICENSE +202 -0
vigrise/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Code the services share: endpoint resolution, signed requests, multipart bodies, pagination and the error envelope."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Where a ViGrise service lives: the explicit endpoint, then `VIGRISE_ENDPOINT_URL_<SERVICE>`, then
|
|
2
|
+
`VIGRISE_ENDPOINT_URL`, then `https://<service>-api.vigrise.com`."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Mapping, Optional
|
|
6
|
+
|
|
7
|
+
ASSUME_ROLE_PATH = "/v1/assume-role"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def env_key(service: str) -> str:
|
|
11
|
+
"""The environment variable of one service, such as `VIGRISE_ENDPOINT_URL_VOICE_STUDIO` for `voice-studio`."""
|
|
12
|
+
return "VIGRISE_ENDPOINT_URL_" + service.upper().replace("-", "_")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_endpoint(
|
|
16
|
+
service: str, explicit: Optional[str] = None, env: Optional[Mapping[str, str]] = None
|
|
17
|
+
) -> str:
|
|
18
|
+
"""The endpoint of `service`. Empty values count as unset and a trailing slash is dropped."""
|
|
19
|
+
env = os.environ if env is None else env
|
|
20
|
+
endpoint = (
|
|
21
|
+
explicit
|
|
22
|
+
or env.get(env_key(service))
|
|
23
|
+
or env.get("VIGRISE_ENDPOINT_URL")
|
|
24
|
+
or f"https://{service}-api.vigrise.com"
|
|
25
|
+
)
|
|
26
|
+
return endpoint.rstrip("/")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sts_url(explicit: Optional[str] = None, env: Optional[Mapping[str, str]] = None) -> str:
|
|
30
|
+
"""The URL of the role session call. The STS endpoint is accepted with or without the `/v1/assume-role` path."""
|
|
31
|
+
endpoint = resolve_endpoint("sts", explicit, env)
|
|
32
|
+
return endpoint if endpoint.endswith(ASSUME_ROLE_PATH) else endpoint + ASSUME_ROLE_PATH
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sts_base_url(explicit: Optional[str] = None, env: Optional[Mapping[str, str]] = None) -> str:
|
|
36
|
+
"""The base URL of the STS endpoint, without the `/v1/assume-role` path."""
|
|
37
|
+
endpoint = resolve_endpoint("sts", explicit, env)
|
|
38
|
+
return endpoint[: -len(ASSUME_ROLE_PATH)] if endpoint.endswith(ASSUME_ROLE_PATH) else endpoint
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The error envelope of the service APIs: `{"error": {"code", "message", ...}}`."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ErrorEnvelope:
|
|
10
|
+
code: Optional[str]
|
|
11
|
+
message: str
|
|
12
|
+
# The resource an answer refers to.
|
|
13
|
+
resource_id: Optional[str]
|
|
14
|
+
# The fields besides `code`, `message` and `resource_id`.
|
|
15
|
+
details: Dict[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_error_envelope(body: bytes) -> Optional[ErrorEnvelope]:
|
|
19
|
+
"""The envelope a response body carries; None when the body is not one."""
|
|
20
|
+
try:
|
|
21
|
+
envelope = json.loads(body).get("error")
|
|
22
|
+
except (ValueError, AttributeError):
|
|
23
|
+
return None
|
|
24
|
+
if not isinstance(envelope, dict):
|
|
25
|
+
return None
|
|
26
|
+
details = dict(envelope)
|
|
27
|
+
code = details.pop("code", None)
|
|
28
|
+
message = details.pop("message", "")
|
|
29
|
+
resource_id = details.pop("resource_id", None)
|
|
30
|
+
return ErrorEnvelope(code if isinstance(code, str) else None, message, resource_id, details)
|
vigrise/_common/http.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Request building shared by the service clients: the sync and async clients differ only in sending."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import AsyncIterable, Dict, Iterable, List, Optional, Tuple, Union
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from ..auth import UNSIGNED_PAYLOAD, sign_request
|
|
8
|
+
from ..auth.credentials import CredentialsSource, credentials_from
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Request:
|
|
13
|
+
"""A request before it is signed: the path is relative to the endpoint."""
|
|
14
|
+
|
|
15
|
+
method: str
|
|
16
|
+
path: str
|
|
17
|
+
query: List[Tuple[str, str]] = field(default_factory=list)
|
|
18
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
19
|
+
content: bytes = b""
|
|
20
|
+
# A body that streams; it is signed as UNSIGNED_PAYLOAD and needs a content-length header.
|
|
21
|
+
stream: Optional[Union[Iterable[bytes], AsyncIterable[bytes]]] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def segment(value: str) -> str:
|
|
25
|
+
"""A path segment with everything but unreserved characters percent-encoded."""
|
|
26
|
+
return quote(value, safe="")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def query_pairs(**values: object) -> List[Tuple[str, str]]:
|
|
30
|
+
"""The query parameters that have a value, in the order given; booleans are `true` or `false`."""
|
|
31
|
+
pairs = []
|
|
32
|
+
for name, value in values.items():
|
|
33
|
+
if value is None:
|
|
34
|
+
continue
|
|
35
|
+
pairs.append(
|
|
36
|
+
(
|
|
37
|
+
name,
|
|
38
|
+
("true" if value else "false")
|
|
39
|
+
if isinstance(value, bool)
|
|
40
|
+
else str(getattr(value, "value", value)),
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
return pairs
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def encode_query(pairs: Iterable[Tuple[str, str]]) -> str:
|
|
47
|
+
return "&".join(f"{quote(name, safe='')}={quote(value, safe='')}" for name, value in pairs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def prepare(
|
|
51
|
+
request: Request, endpoint: str, service: str, credentials: Optional[CredentialsSource]
|
|
52
|
+
) -> Tuple[str, Dict[str, str]]:
|
|
53
|
+
"""The URL to send and the headers, signed for `service` with VGR4."""
|
|
54
|
+
url = endpoint.rstrip("/") + request.path
|
|
55
|
+
if request.query:
|
|
56
|
+
url += "?" + encode_query(request.query)
|
|
57
|
+
headers = dict(request.headers)
|
|
58
|
+
headers.update(
|
|
59
|
+
sign_request(
|
|
60
|
+
request.method,
|
|
61
|
+
url,
|
|
62
|
+
headers,
|
|
63
|
+
request.content,
|
|
64
|
+
credentials_from(credentials),
|
|
65
|
+
service,
|
|
66
|
+
payload_hash=UNSIGNED_PAYLOAD if request.stream is not None else None,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
return url, headers
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from typing import List, Optional, Tuple
|
|
3
|
+
|
|
4
|
+
# name, body, file name, content type
|
|
5
|
+
Part = Tuple[str, bytes, Optional[str], Optional[str]]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def encode_multipart(parts: List[Part]) -> Tuple[str, bytes]:
|
|
9
|
+
"""The `Content-Type` header value and the `multipart/form-data` body. The boundary is derived from the parts, so it cannot occur inside them."""
|
|
10
|
+
for _, _, _, content_type in parts:
|
|
11
|
+
if content_type is not None and ("\r" in content_type or "\n" in content_type):
|
|
12
|
+
raise ValueError("a content type cannot contain line breaks")
|
|
13
|
+
salt = 0
|
|
14
|
+
while True:
|
|
15
|
+
digest = hashlib.sha256(str(salt).encode())
|
|
16
|
+
for _, body, _, _ in parts:
|
|
17
|
+
digest.update(body)
|
|
18
|
+
boundary = "vigrise-" + digest.hexdigest()[:32]
|
|
19
|
+
if not any(boundary.encode() in body for _, body, _, _ in parts):
|
|
20
|
+
break
|
|
21
|
+
salt += 1
|
|
22
|
+
out = bytearray()
|
|
23
|
+
for name, body, file_name, content_type in parts:
|
|
24
|
+
out += f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"'.encode()
|
|
25
|
+
if file_name is not None:
|
|
26
|
+
out += f'; filename="{file_name.replace(chr(34), "%22").replace(chr(13), "%0D").replace(chr(10), "%0A")}"\r\nContent-Type: {content_type}'.encode()
|
|
27
|
+
out += b"\r\n\r\n" + body + b"\r\n"
|
|
28
|
+
out += f"--{boundary}--\r\n".encode()
|
|
29
|
+
return f"multipart/form-data; boundary={boundary}", bytes(out)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Generic, List, Optional, TypeVar
|
|
3
|
+
|
|
4
|
+
T = TypeVar("T")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Page(Generic[T]):
|
|
9
|
+
data: List[T]
|
|
10
|
+
# Pass as `cursor` to read the next page; None on the last page.
|
|
11
|
+
next_cursor: Optional[str] = None
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Sending of signed requests, shared by the service clients. The sync and async senders differ only in how they send."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from . import http as _http
|
|
9
|
+
from ..auth.credentials import CredentialsSource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Failures:
|
|
14
|
+
"""The errors a service client raises."""
|
|
15
|
+
|
|
16
|
+
signing: Callable[[str], Exception]
|
|
17
|
+
transport: Callable[[str], Exception]
|
|
18
|
+
response: Callable[[int, bytes], Exception]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Sender:
|
|
22
|
+
def _refused(self, status: int) -> None:
|
|
23
|
+
"""Tells a credentials provider that the service refused its credentials, for example a revoked session."""
|
|
24
|
+
if status in (401, 403):
|
|
25
|
+
invalidate = getattr(self.credentials, "invalidate", None)
|
|
26
|
+
if callable(invalidate):
|
|
27
|
+
invalidate()
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
service: str,
|
|
32
|
+
endpoint: str,
|
|
33
|
+
credentials: Optional[CredentialsSource],
|
|
34
|
+
timeout: Optional[float],
|
|
35
|
+
failures: Failures,
|
|
36
|
+
):
|
|
37
|
+
self.service = service
|
|
38
|
+
self.endpoint = endpoint.rstrip("/")
|
|
39
|
+
self.credentials = credentials
|
|
40
|
+
self.timeout = timeout
|
|
41
|
+
self.failures = failures
|
|
42
|
+
|
|
43
|
+
def _build(self, http, request: _http.Request) -> httpx.Request:
|
|
44
|
+
try:
|
|
45
|
+
url, headers = _http.prepare(request, self.endpoint, self.service, self.credentials)
|
|
46
|
+
except Exception as error:
|
|
47
|
+
raise self.failures.signing(str(error)) from error
|
|
48
|
+
content = request.stream if request.stream is not None else request.content
|
|
49
|
+
return http.build_request(
|
|
50
|
+
request.method, url, headers=headers, content=content, timeout=self.timeout
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SyncSender(_Sender):
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
service,
|
|
58
|
+
endpoint,
|
|
59
|
+
credentials,
|
|
60
|
+
timeout,
|
|
61
|
+
failures,
|
|
62
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
63
|
+
):
|
|
64
|
+
super().__init__(service, endpoint, credentials, timeout, failures)
|
|
65
|
+
self.http = httpx.Client(transport=transport)
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
self.http.close()
|
|
69
|
+
|
|
70
|
+
def send(self, request: _http.Request, stream: bool = False) -> httpx.Response:
|
|
71
|
+
"""The response of a request that succeeded; with `stream`, its body is not read yet."""
|
|
72
|
+
built = self._build(self.http, request)
|
|
73
|
+
try:
|
|
74
|
+
response = self.http.send(built, stream=stream)
|
|
75
|
+
except httpx.HTTPError as error:
|
|
76
|
+
raise self.failures.transport(str(error)) from error
|
|
77
|
+
if not response.is_success:
|
|
78
|
+
body = response.read()
|
|
79
|
+
response.close()
|
|
80
|
+
self._refused(response.status_code)
|
|
81
|
+
raise self.failures.response(response.status_code, body)
|
|
82
|
+
return response
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AsyncSender(_Sender):
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
service,
|
|
89
|
+
endpoint,
|
|
90
|
+
credentials,
|
|
91
|
+
timeout,
|
|
92
|
+
failures,
|
|
93
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
94
|
+
):
|
|
95
|
+
super().__init__(service, endpoint, credentials, timeout, failures)
|
|
96
|
+
self.http = httpx.AsyncClient(transport=transport)
|
|
97
|
+
|
|
98
|
+
async def aclose(self) -> None:
|
|
99
|
+
await self.http.aclose()
|
|
100
|
+
|
|
101
|
+
async def send(self, request: _http.Request, stream: bool = False) -> httpx.Response:
|
|
102
|
+
"""The response of a request that succeeded; with `stream`, its body is not read yet."""
|
|
103
|
+
built = self._build(self.http, request)
|
|
104
|
+
try:
|
|
105
|
+
response = await self.http.send(built, stream=stream)
|
|
106
|
+
except httpx.HTTPError as error:
|
|
107
|
+
raise self.failures.transport(str(error)) from error
|
|
108
|
+
if not response.is_success:
|
|
109
|
+
body = await response.aread()
|
|
110
|
+
await response.aclose()
|
|
111
|
+
self._refused(response.status_code)
|
|
112
|
+
raise self.failures.response(response.status_code, body)
|
|
113
|
+
return response
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""ViGrise Accounts for Python services: SSO introspection, back-channel logout verification and OAuth2/OIDC helpers."""
|
|
2
|
+
|
|
3
|
+
from .backchannel import BackchannelLogout
|
|
4
|
+
from .errors import (
|
|
5
|
+
AccountsError,
|
|
6
|
+
AccountsMisconfiguredError,
|
|
7
|
+
AccountsUnavailableError,
|
|
8
|
+
InactiveSessionError,
|
|
9
|
+
InvalidTokenError,
|
|
10
|
+
JwksError,
|
|
11
|
+
TokenExchangeError,
|
|
12
|
+
)
|
|
13
|
+
from .hash import SSO_HEADER, sso_token_hash
|
|
14
|
+
from .introspect import AsyncSsoIntrospector, SsoIntrospector
|
|
15
|
+
from .jwks import Jwks
|
|
16
|
+
from .dto import (
|
|
17
|
+
IdTokenClaims,
|
|
18
|
+
Introspection,
|
|
19
|
+
LogoutResult,
|
|
20
|
+
SsoAccount,
|
|
21
|
+
SsoIdentity,
|
|
22
|
+
SsoSession,
|
|
23
|
+
TokenResponse,
|
|
24
|
+
)
|
|
25
|
+
from .oauth2 import (
|
|
26
|
+
AsyncOAuth2Client,
|
|
27
|
+
AuthorizationRequest,
|
|
28
|
+
IdTokenVerifier,
|
|
29
|
+
OAuth2Client,
|
|
30
|
+
Pkce,
|
|
31
|
+
end_session_url,
|
|
32
|
+
random_state,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
name
|
|
37
|
+
for name in dir()
|
|
38
|
+
if name[:1].isupper() or name in ("sso_token_hash", "end_session_url", "random_state")
|
|
39
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from .errors import InvalidTokenError
|
|
6
|
+
from .jwks import Jwks
|
|
7
|
+
from .dto import LogoutResult
|
|
8
|
+
from .verify import LEEWAY_SECONDS, decode, unverified_header
|
|
9
|
+
|
|
10
|
+
EVENT = "http://schemas.openid.net/event/backchannel-logout"
|
|
11
|
+
MAX_AGE_SECONDS = 300
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BackchannelLogout:
|
|
15
|
+
"""Verifies back-channel `logout_token`s sent by Accounts: signature against the JWKS, `iss`, `aud`, the
|
|
16
|
+
logout event, the time window and `jti` replay."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, issuer: str, client_id: str, jwks: Jwks):
|
|
19
|
+
self._issuer = issuer
|
|
20
|
+
self._client_id = client_id
|
|
21
|
+
self._jwks = jwks
|
|
22
|
+
self._seen: Dict[str, int] = {}
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
def verify(self, logout_token: str) -> LogoutResult:
|
|
26
|
+
header = unverified_header(logout_token, "logout token")
|
|
27
|
+
return self._check(logout_token, self._jwks.key(header))
|
|
28
|
+
|
|
29
|
+
async def averify(self, logout_token: str) -> LogoutResult:
|
|
30
|
+
"""Like `verify`, fetching the signing keys with an async client."""
|
|
31
|
+
header = unverified_header(logout_token, "logout token")
|
|
32
|
+
return self._check(logout_token, await self._jwks.akey(header))
|
|
33
|
+
|
|
34
|
+
def _check(self, logout_token: str, key: Any) -> LogoutResult:
|
|
35
|
+
claims = decode(
|
|
36
|
+
logout_token,
|
|
37
|
+
key,
|
|
38
|
+
"logout token",
|
|
39
|
+
self._issuer,
|
|
40
|
+
self._client_id,
|
|
41
|
+
["iss", "aud", "exp", "iat"],
|
|
42
|
+
LEEWAY_SECONDS,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def invalid(message: str) -> InvalidTokenError:
|
|
46
|
+
return InvalidTokenError(f"invalid logout token: {message}")
|
|
47
|
+
|
|
48
|
+
events = claims.get("events")
|
|
49
|
+
if not isinstance(events, dict) or events.get(EVENT) is None:
|
|
50
|
+
raise invalid("missing backchannel-logout event")
|
|
51
|
+
if "nonce" in claims:
|
|
52
|
+
raise invalid("nonce is not allowed")
|
|
53
|
+
if "sid" not in claims and "sub" not in claims:
|
|
54
|
+
raise invalid("neither sid nor sub")
|
|
55
|
+
jti = claims.get("jti")
|
|
56
|
+
if not isinstance(jti, str) or not jti:
|
|
57
|
+
raise invalid("missing jti")
|
|
58
|
+
now = int(time.time())
|
|
59
|
+
if (
|
|
60
|
+
claims["iat"] > now + LEEWAY_SECONDS or claims["iat"] < now - MAX_AGE_SECONDS - LEEWAY_SECONDS
|
|
61
|
+
):
|
|
62
|
+
raise invalid("issued outside the accepted time window")
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._seen = {seen: exp for seen, exp in self._seen.items() if exp >= now}
|
|
65
|
+
replayed = jti in self._seen
|
|
66
|
+
self._seen[jti] = claims["exp"]
|
|
67
|
+
return LogoutResult(
|
|
68
|
+
"replayed" if replayed else "fresh", jti, claims.get("sub"), claims.get("sid")
|
|
69
|
+
)
|
vigrise/accounts/dto.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from ..auth.sts import parse_timestamp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SsoIdentity:
|
|
10
|
+
"""The person behind an SSO session."""
|
|
11
|
+
|
|
12
|
+
# Accounts identity id, the `sub` apps key their users by.
|
|
13
|
+
id: str
|
|
14
|
+
email: Optional[str] = None
|
|
15
|
+
name: Optional[str] = None
|
|
16
|
+
picture: Optional[str] = None
|
|
17
|
+
email_verified: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SsoSession:
|
|
22
|
+
"""An Accounts session."""
|
|
23
|
+
|
|
24
|
+
# Session id, the `sso_sid` of app sessions and the `sid` of logout tokens.
|
|
25
|
+
id: str
|
|
26
|
+
authenticated_at: datetime
|
|
27
|
+
# Authenticator assurance level.
|
|
28
|
+
aal: str
|
|
29
|
+
expires_at: datetime
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SsoAccount:
|
|
34
|
+
"""An account in the browser's group, for the account menu."""
|
|
35
|
+
|
|
36
|
+
# Session id to switch to.
|
|
37
|
+
session_id: str
|
|
38
|
+
identity_id: str
|
|
39
|
+
active: bool
|
|
40
|
+
name: Optional[str] = None
|
|
41
|
+
email: Optional[str] = None
|
|
42
|
+
picture: Optional[str] = None
|
|
43
|
+
# Whether the session expired and needs re-authentication.
|
|
44
|
+
expired: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Introspection:
|
|
49
|
+
"""The answer of the introspection endpoint."""
|
|
50
|
+
|
|
51
|
+
# False for an unknown, revoked or ended token.
|
|
52
|
+
active: bool
|
|
53
|
+
identity: Optional[SsoIdentity] = None
|
|
54
|
+
session: Optional[SsoSession] = None
|
|
55
|
+
accounts: List[SsoAccount] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Introspection":
|
|
59
|
+
identity = data.get("identity")
|
|
60
|
+
session = data.get("session")
|
|
61
|
+
return cls(
|
|
62
|
+
active=bool(data.get("active")),
|
|
63
|
+
identity=SsoIdentity(
|
|
64
|
+
id=identity["id"],
|
|
65
|
+
email=identity.get("email"),
|
|
66
|
+
name=identity.get("name"),
|
|
67
|
+
picture=identity.get("picture"),
|
|
68
|
+
email_verified=bool(identity.get("email_verified", False)),
|
|
69
|
+
)
|
|
70
|
+
if identity
|
|
71
|
+
else None,
|
|
72
|
+
session=SsoSession(
|
|
73
|
+
id=session["id"],
|
|
74
|
+
authenticated_at=parse_timestamp(session["authenticated_at"]),
|
|
75
|
+
aal=session["aal"],
|
|
76
|
+
expires_at=parse_timestamp(session["expires_at"]),
|
|
77
|
+
)
|
|
78
|
+
if session
|
|
79
|
+
else None,
|
|
80
|
+
accounts=[
|
|
81
|
+
SsoAccount(
|
|
82
|
+
session_id=item["session_id"],
|
|
83
|
+
identity_id=item["identity_id"],
|
|
84
|
+
active=bool(item["active"]),
|
|
85
|
+
name=item.get("name"),
|
|
86
|
+
email=item.get("email"),
|
|
87
|
+
picture=item.get("picture"),
|
|
88
|
+
expired=bool(item.get("expired", False)),
|
|
89
|
+
)
|
|
90
|
+
for item in data.get("accounts") or []
|
|
91
|
+
],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class LogoutResult:
|
|
97
|
+
"""The claims of a verified `logout_token`, and whether its `jti` was seen before."""
|
|
98
|
+
|
|
99
|
+
# `fresh` for the first delivery: end the session. `replayed` for a `jti` already processed: answer 200 and do nothing.
|
|
100
|
+
outcome: str
|
|
101
|
+
jti: str
|
|
102
|
+
# Client-facing subject of the person.
|
|
103
|
+
sub: Optional[str] = None
|
|
104
|
+
# Accounts session id that ended.
|
|
105
|
+
sid: Optional[str] = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class TokenResponse:
|
|
110
|
+
"""The token endpoint answer."""
|
|
111
|
+
|
|
112
|
+
access_token: str
|
|
113
|
+
# Present for the `openid` scope.
|
|
114
|
+
id_token: Optional[str] = None
|
|
115
|
+
refresh_token: Optional[str] = None
|
|
116
|
+
# Access token lifetime in seconds.
|
|
117
|
+
expires_in: Optional[int] = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class IdTokenClaims:
|
|
122
|
+
"""Claims of a verified ID token."""
|
|
123
|
+
|
|
124
|
+
# Client-facing subject.
|
|
125
|
+
sub: str
|
|
126
|
+
# Accounts session id.
|
|
127
|
+
sid: Optional[str] = None
|
|
128
|
+
# When the person authenticated, in seconds since the epoch.
|
|
129
|
+
auth_time: Optional[int] = None
|
|
130
|
+
# Nonce echoed from the authorization request.
|
|
131
|
+
nonce: Optional[str] = None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AccountsError(Exception):
|
|
5
|
+
"""Base of the errors of the Accounts helpers."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AccountsUnavailableError(AccountsError):
|
|
9
|
+
"""Accounts could not be reached, is rate limiting the client, or answered with a server error. Retry later."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, status: Optional[int] = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.status = status
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AccountsMisconfiguredError(AccountsError):
|
|
17
|
+
"""Accounts rejected the client or answered in an unexpected way: the credentials, the client's permissions or the internal URL need attention."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, status: Optional[int] = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status = status
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InactiveSessionError(AccountsError):
|
|
25
|
+
"""`introspect_active` found a token that is unknown, revoked or ended."""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
super().__init__("the SSO token is not active")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InvalidTokenError(AccountsError):
|
|
32
|
+
"""A logout token or ID token failed verification."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TokenExchangeError(AccountsError):
|
|
36
|
+
"""The authorization code exchange failed."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class JwksError(AccountsError):
|
|
40
|
+
"""The signing keys could not be loaded or did not contain the key id."""
|
vigrise/accounts/hash.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
# Request header that carries the `vigrise_sso` cookie value from an app's frontend to its backend.
|
|
4
|
+
SSO_HEADER = "x-vigrise-sso"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def sso_token_hash(token: str) -> str:
|
|
8
|
+
"""Hash of a `vigrise_sso` cookie value: lowercase hex SHA-256. Apps store it to notice when the cookie changes."""
|
|
9
|
+
return hashlib.sha256(token.encode()).hexdigest()
|