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 ADDED
@@ -0,0 +1,4 @@
1
+ """ViGrise SDK: VGR4 request signing, ViGrise Accounts, ViGrise Storage and the Speech API.
2
+
3
+ Import the module you need: `vigrise.auth`, `vigrise.speech`, `vigrise.storage` or `vigrise.accounts`.
4
+ """
@@ -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)
@@ -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
+ )
@@ -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."""
@@ -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()