ovok-sdk 0.1.0.dev13__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.
- ovok_sdk/__init__.py +73 -0
- ovok_sdk/auth/__init__.py +0 -0
- ovok_sdk/auth/access_token.py +21 -0
- ovok_sdk/auth/client_credentials.py +41 -0
- ovok_sdk/auth/credentials.py +20 -0
- ovok_sdk/auth/pkce.py +28 -0
- ovok_sdk/auth/session.py +54 -0
- ovok_sdk/auth/session_store.py +77 -0
- ovok_sdk/auth/single_flight.py +44 -0
- ovok_sdk/auth/token_endpoint.py +45 -0
- ovok_sdk/auth/user_session.py +89 -0
- ovok_sdk/clock.py +11 -0
- ovok_sdk/config.py +118 -0
- ovok_sdk/errors.py +96 -0
- ovok_sdk/http/__init__.py +0 -0
- ovok_sdk/http/client.py +108 -0
- ovok_sdk/http/error_response.py +246 -0
- ovok_sdk/http/request.py +16 -0
- ovok_sdk/http/retry.py +59 -0
- ovok_sdk/http/transport.py +94 -0
- ovok_sdk/http/url.py +53 -0
- ovok_sdk/ovok.py +125 -0
- ovok_sdk/paging.py +117 -0
- ovok_sdk/py.typed +0 -0
- ovok_sdk/resources/__init__.py +0 -0
- ovok_sdk/resources/ai.py +55 -0
- ovok_sdk/resources/auth.py +177 -0
- ovok_sdk/resources/auth_sessions.py +27 -0
- ovok_sdk/resources/bots.py +16 -0
- ovok_sdk/resources/cms.py +159 -0
- ovok_sdk/resources/consent.py +42 -0
- ovok_sdk/resources/content.py +58 -0
- ovok_sdk/resources/documents.py +41 -0
- ovok_sdk/resources/fhir.py +89 -0
- ovok_sdk/resources/health.py +16 -0
- ovok_sdk/resources/invites.py +44 -0
- ovok_sdk/resources/measurement_builder.py +708 -0
- ovok_sdk/resources/measurements.py +649 -0
- ovok_sdk/resources/patient.py +81 -0
- ovok_sdk/resources/platform.py +141 -0
- ovok_sdk/resources/questionnaires.py +61 -0
- ovok_sdk/resources/registration.py +55 -0
- ovok_sdk/resources/sharing.py +59 -0
- ovok_sdk/resources/signals.py +75 -0
- ovok_sdk/resources/slim.py +431 -0
- ovok_sdk/resources/video_calls.py +63 -0
- ovok_sdk/resources/wearables.py +52 -0
- ovok_sdk/storage.py +25 -0
- ovok_sdk/values.py +10 -0
- ovok_sdk-0.1.0.dev13.dist-info/METADATA +287 -0
- ovok_sdk-0.1.0.dev13.dist-info/RECORD +53 -0
- ovok_sdk-0.1.0.dev13.dist-info/WHEEL +4 -0
- ovok_sdk-0.1.0.dev13.dist-info/licenses/LICENSE +661 -0
ovok_sdk/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from ovok_sdk.auth.session import OvokReference, Session
|
|
2
|
+
from ovok_sdk.config import UnauthenticatedReason
|
|
3
|
+
from ovok_sdk.errors import (
|
|
4
|
+
FieldError,
|
|
5
|
+
OvokAuthenticationError,
|
|
6
|
+
OvokConfigError,
|
|
7
|
+
OvokConflictError,
|
|
8
|
+
OvokConnectionError,
|
|
9
|
+
OvokError,
|
|
10
|
+
OvokErrorDetails,
|
|
11
|
+
OvokNotFoundError,
|
|
12
|
+
OvokPermissionError,
|
|
13
|
+
OvokRateLimitError,
|
|
14
|
+
OvokServiceError,
|
|
15
|
+
OvokValidationError,
|
|
16
|
+
)
|
|
17
|
+
from ovok_sdk.http.transport import (
|
|
18
|
+
OvokRequest,
|
|
19
|
+
OvokResponse,
|
|
20
|
+
Transport,
|
|
21
|
+
UrllibTransport,
|
|
22
|
+
)
|
|
23
|
+
from ovok_sdk.ovok import Ovok
|
|
24
|
+
from ovok_sdk.paging import Page
|
|
25
|
+
from ovok_sdk.resources.auth import (
|
|
26
|
+
LoginResult,
|
|
27
|
+
MfaChallenge,
|
|
28
|
+
MfaRequired,
|
|
29
|
+
RegisterResult,
|
|
30
|
+
SignedIn,
|
|
31
|
+
)
|
|
32
|
+
from ovok_sdk.resources.measurement_builder import MeasurementTypeKey
|
|
33
|
+
from ovok_sdk.resources.registration import (
|
|
34
|
+
ClinicianInvite,
|
|
35
|
+
PatientRegistration,
|
|
36
|
+
Registration,
|
|
37
|
+
)
|
|
38
|
+
from ovok_sdk.storage import MemoryStorage, OvokStorage
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"ClinicianInvite",
|
|
42
|
+
"FieldError",
|
|
43
|
+
"LoginResult",
|
|
44
|
+
"MeasurementTypeKey",
|
|
45
|
+
"MemoryStorage",
|
|
46
|
+
"MfaChallenge",
|
|
47
|
+
"MfaRequired",
|
|
48
|
+
"Ovok",
|
|
49
|
+
"OvokAuthenticationError",
|
|
50
|
+
"OvokConfigError",
|
|
51
|
+
"OvokConflictError",
|
|
52
|
+
"OvokConnectionError",
|
|
53
|
+
"OvokError",
|
|
54
|
+
"OvokErrorDetails",
|
|
55
|
+
"OvokNotFoundError",
|
|
56
|
+
"OvokPermissionError",
|
|
57
|
+
"OvokRateLimitError",
|
|
58
|
+
"OvokReference",
|
|
59
|
+
"OvokRequest",
|
|
60
|
+
"OvokResponse",
|
|
61
|
+
"OvokServiceError",
|
|
62
|
+
"OvokStorage",
|
|
63
|
+
"OvokValidationError",
|
|
64
|
+
"Page",
|
|
65
|
+
"PatientRegistration",
|
|
66
|
+
"RegisterResult",
|
|
67
|
+
"Registration",
|
|
68
|
+
"Session",
|
|
69
|
+
"SignedIn",
|
|
70
|
+
"Transport",
|
|
71
|
+
"UnauthenticatedReason",
|
|
72
|
+
"UrllibTransport",
|
|
73
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from ovok_sdk.clock import MS_PER_SECOND
|
|
6
|
+
|
|
7
|
+
REFRESH_MARGIN_MS = 60_000
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def expires_at_for(expires_in_seconds: float, now_ms: int) -> int:
|
|
11
|
+
return now_ms + round(expires_in_seconds * MS_PER_SECOND)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class AccessToken:
|
|
16
|
+
value: str = field(repr=False)
|
|
17
|
+
expires_at_ms: int
|
|
18
|
+
|
|
19
|
+
def is_stale_at(self, now_ms: int) -> bool:
|
|
20
|
+
"""Stale a minute early, so a token never expires between the check and the request."""
|
|
21
|
+
return now_ms >= self.expires_at_ms - REFRESH_MARGIN_MS
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ovok_sdk.auth.access_token import AccessToken, expires_at_for
|
|
4
|
+
from ovok_sdk.auth.single_flight import SingleFlight
|
|
5
|
+
from ovok_sdk.auth.token_endpoint import TokenEndpoint
|
|
6
|
+
from ovok_sdk.clock import Clock, epoch_ms
|
|
7
|
+
from ovok_sdk.config import MachineClient
|
|
8
|
+
|
|
9
|
+
CLIENT_CREDENTIALS_GRANT = "client_credentials"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClientCredentials:
|
|
13
|
+
"""A machine client's own token, fetched from the OAuth endpoint and renewed before it expires."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, endpoint: TokenEndpoint, client: MachineClient, clock: Clock = epoch_ms) -> None:
|
|
16
|
+
self._endpoint = endpoint
|
|
17
|
+
self._client = client
|
|
18
|
+
self._clock = clock
|
|
19
|
+
self._token: AccessToken | None = None
|
|
20
|
+
self._flight: SingleFlight[AccessToken] = SingleFlight()
|
|
21
|
+
|
|
22
|
+
def access_token(self) -> str:
|
|
23
|
+
token = self._token
|
|
24
|
+
if token is None or token.is_stale_at(self._clock()):
|
|
25
|
+
token = self._flight.run(self._request_token)
|
|
26
|
+
return token.value
|
|
27
|
+
|
|
28
|
+
def refresh(self) -> bool:
|
|
29
|
+
self._flight.run(self._request_token)
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
def _request_token(self) -> AccessToken:
|
|
33
|
+
response = self._endpoint.request(
|
|
34
|
+
{
|
|
35
|
+
"grant_type": CLIENT_CREDENTIALS_GRANT,
|
|
36
|
+
"client_id": self._client.client_id,
|
|
37
|
+
"client_secret": self._client.client_secret,
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
self._token = AccessToken(response.access_token, expires_at_for(response.expires_in, self._clock()))
|
|
41
|
+
return self._token
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Credentials(Protocol):
|
|
7
|
+
def access_token(self) -> str | None: ...
|
|
8
|
+
|
|
9
|
+
def refresh(self) -> bool: ...
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _NoCredentials:
|
|
13
|
+
def access_token(self) -> None:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
def refresh(self) -> bool:
|
|
17
|
+
return False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
NO_CREDENTIALS: Credentials = _NoCredentials()
|
ovok_sdk/auth/pkce.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
import string
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
VERIFIER_LENGTH = 128
|
|
10
|
+
UNRESERVED_CHARACTERS = string.ascii_letters + string.digits + "-._~"
|
|
11
|
+
ASCII = "ascii"
|
|
12
|
+
BASE64_PADDING = "="
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PkcePair:
|
|
17
|
+
code_verifier: str = field(repr=False)
|
|
18
|
+
code_challenge: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_pkce_pair() -> PkcePair:
|
|
22
|
+
verifier = "".join(secrets.choice(UNRESERVED_CHARACTERS) for _ in range(VERIFIER_LENGTH))
|
|
23
|
+
return PkcePair(verifier, code_challenge_for(verifier))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def code_challenge_for(verifier: str) -> str:
|
|
27
|
+
digest = hashlib.sha256(verifier.encode(ASCII)).digest()
|
|
28
|
+
return base64.urlsafe_b64encode(digest).decode(ASCII).rstrip(BASE64_PADDING)
|
ovok_sdk/auth/session.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ovok_sdk.auth.access_token import AccessToken, expires_at_for
|
|
8
|
+
from ovok_sdk.auth.token_endpoint import TokenResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class OvokReference:
|
|
13
|
+
reference: str
|
|
14
|
+
display: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Session:
|
|
19
|
+
access_token: str = field(repr=False)
|
|
20
|
+
refresh_token: str | None = field(repr=False)
|
|
21
|
+
expires_at_ms: int
|
|
22
|
+
project: OvokReference
|
|
23
|
+
profile: OvokReference
|
|
24
|
+
|
|
25
|
+
def is_stale_at(self, now_ms: int) -> bool:
|
|
26
|
+
return AccessToken(self.access_token, self.expires_at_ms).is_stale_at(now_ms)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def session_from_login(tokens: Mapping[str, Any], now_ms: int) -> Session:
|
|
30
|
+
"""`tokens` is the camelCase body every Ovok sign-in route answers with."""
|
|
31
|
+
return Session(
|
|
32
|
+
access_token=tokens["accessToken"],
|
|
33
|
+
refresh_token=tokens.get("refreshToken"),
|
|
34
|
+
expires_at_ms=expires_at_for(tokens["expiresIn"], now_ms),
|
|
35
|
+
project=reference_from_json(tokens["project"]),
|
|
36
|
+
profile=reference_from_json(tokens["profile"]),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def session_after_refresh(session: Session, response: TokenResponse, now_ms: int) -> Session:
|
|
41
|
+
return replace(
|
|
42
|
+
session,
|
|
43
|
+
access_token=response.access_token,
|
|
44
|
+
refresh_token=response.refresh_token or session.refresh_token,
|
|
45
|
+
expires_at_ms=expires_at_for(response.expires_in, now_ms),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def reference_from_json(value: Mapping[str, Any]) -> OvokReference:
|
|
50
|
+
return OvokReference(value["reference"], value.get("display"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reference_to_json(reference: OvokReference) -> dict[str, Any]:
|
|
54
|
+
return {"reference": reference.reference, "display": reference.display}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ovok_sdk.auth.session import Session, reference_from_json, reference_to_json
|
|
7
|
+
from ovok_sdk.storage import OvokStorage
|
|
8
|
+
from ovok_sdk.values import finite_number
|
|
9
|
+
|
|
10
|
+
SESSION_KEY = "ovok:{tenant_code}:session"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SessionStore:
|
|
14
|
+
"""Keeps the session as JSON in the TypeScript SDK's shape, so stores are interchangeable."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, storage: OvokStorage, key: str) -> None:
|
|
17
|
+
self._storage = storage
|
|
18
|
+
self._key = key
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def for_tenant(cls, storage: OvokStorage, tenant_code: str) -> SessionStore:
|
|
22
|
+
"""One key per tenant, so clearing a session never touches anything else in shared storage."""
|
|
23
|
+
return cls(storage, SESSION_KEY.format(tenant_code=tenant_code))
|
|
24
|
+
|
|
25
|
+
def load(self) -> Session | None:
|
|
26
|
+
raw = self._storage.get_item(self._key)
|
|
27
|
+
return None if raw is None else _session_from_json(raw)
|
|
28
|
+
|
|
29
|
+
def save(self, session: Session) -> None:
|
|
30
|
+
self._storage.set_item(self._key, _session_to_json(session))
|
|
31
|
+
|
|
32
|
+
def clear(self) -> None:
|
|
33
|
+
self._storage.remove_item(self._key)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _session_to_json(session: Session) -> str:
|
|
37
|
+
return json.dumps(
|
|
38
|
+
{
|
|
39
|
+
"accessToken": session.access_token,
|
|
40
|
+
"refreshToken": session.refresh_token,
|
|
41
|
+
"expiresAt": session.expires_at_ms,
|
|
42
|
+
"project": reference_to_json(session.project),
|
|
43
|
+
"profile": reference_to_json(session.profile),
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _session_from_json(raw: str) -> Session | None:
|
|
49
|
+
try:
|
|
50
|
+
value = json.loads(raw)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
53
|
+
return _session_from(value) if _is_complete(value) else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _session_from(value: dict[str, Any]) -> Session:
|
|
57
|
+
return Session(
|
|
58
|
+
access_token=value["accessToken"],
|
|
59
|
+
refresh_token=value.get("refreshToken"),
|
|
60
|
+
expires_at_ms=value["expiresAt"],
|
|
61
|
+
project=reference_from_json(value["project"]),
|
|
62
|
+
profile=reference_from_json(value["profile"]),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_complete(value: Any) -> bool:
|
|
67
|
+
return (
|
|
68
|
+
isinstance(value, dict)
|
|
69
|
+
and isinstance(value.get("accessToken"), str)
|
|
70
|
+
and finite_number(value.get("expiresAt")) is not None
|
|
71
|
+
and _is_reference(value.get("project"))
|
|
72
|
+
and _is_reference(value.get("profile"))
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_reference(value: Any) -> bool:
|
|
77
|
+
return isinstance(value, dict) and isinstance(value.get("reference"), str)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import partial
|
|
6
|
+
from typing import Generic, NoReturn, TypeVar
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
Settled = Callable[[], T]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SingleFlight(Generic[T]):
|
|
14
|
+
"""Runs one call at a time; callers who arrive while it runs share its outcome instead of repeating it."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._lock = threading.Lock()
|
|
18
|
+
self._finished_calls = 0
|
|
19
|
+
self._latest: Settled[T] = _nothing_ran
|
|
20
|
+
|
|
21
|
+
def run(self, call: Callable[[], T]) -> T:
|
|
22
|
+
calls_seen = self._finished_calls
|
|
23
|
+
with self._lock:
|
|
24
|
+
if self._finished_calls == calls_seen:
|
|
25
|
+
self._latest = _settle(call)
|
|
26
|
+
self._finished_calls += 1
|
|
27
|
+
latest = self._latest
|
|
28
|
+
return latest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _settle(call: Callable[[], T]) -> Settled[T]:
|
|
32
|
+
try:
|
|
33
|
+
value = call()
|
|
34
|
+
except Exception as error:
|
|
35
|
+
return partial(_reraise, error)
|
|
36
|
+
return lambda: value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _reraise(error: Exception) -> NoReturn:
|
|
40
|
+
raise error
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _nothing_ran() -> NoReturn:
|
|
44
|
+
raise RuntimeError("SingleFlight has run nothing yet.")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
from ovok_sdk.config import OvokSettings
|
|
9
|
+
from ovok_sdk.http.error_response import error_from_response
|
|
10
|
+
from ovok_sdk.http.transport import OvokRequest
|
|
11
|
+
|
|
12
|
+
TOKEN_PATH = "oauth2/token"
|
|
13
|
+
FORM_HEADERS = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class TokenResponse:
|
|
18
|
+
access_token: str = field(repr=False)
|
|
19
|
+
expires_in: float
|
|
20
|
+
refresh_token: str | None = field(default=None, repr=False)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_json(cls, body: Mapping[str, Any]) -> TokenResponse:
|
|
24
|
+
return cls(body["access_token"], body["expires_in"], body.get("refresh_token"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TokenEndpoint:
|
|
28
|
+
"""Ovok's OAuth token endpoint: one plain form POST, without retries or credentials."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, settings: OvokSettings) -> None:
|
|
31
|
+
self._settings = settings
|
|
32
|
+
|
|
33
|
+
def request(self, form: Mapping[str, str]) -> TokenResponse:
|
|
34
|
+
response = self._settings.transport(
|
|
35
|
+
OvokRequest(
|
|
36
|
+
method="POST",
|
|
37
|
+
url=f"{self._settings.base_url}{TOKEN_PATH}",
|
|
38
|
+
headers=FORM_HEADERS,
|
|
39
|
+
body=urlencode(form),
|
|
40
|
+
timeout=self._settings.timeout,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
if response.is_success:
|
|
44
|
+
return TokenResponse.from_json(response.json())
|
|
45
|
+
raise error_from_response(response)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ovok_sdk.auth.session import Session, session_after_refresh, session_from_login
|
|
7
|
+
from ovok_sdk.auth.session_store import SessionStore
|
|
8
|
+
from ovok_sdk.auth.single_flight import SingleFlight
|
|
9
|
+
from ovok_sdk.auth.token_endpoint import TokenEndpoint
|
|
10
|
+
from ovok_sdk.clock import Clock, epoch_ms
|
|
11
|
+
from ovok_sdk.config import OvokSettings, UnauthenticatedReason
|
|
12
|
+
from ovok_sdk.errors import OvokError
|
|
13
|
+
|
|
14
|
+
REFRESH_TOKEN_GRANT = "refresh_token"
|
|
15
|
+
REJECTED_REFRESH_STATUSES = frozenset({400, 401, 403})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UserSession:
|
|
19
|
+
"""The signed-in user's tokens, kept in storage and refreshed a minute before they expire."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
store: SessionStore,
|
|
24
|
+
endpoint: TokenEndpoint,
|
|
25
|
+
settings: OvokSettings,
|
|
26
|
+
clock: Clock = epoch_ms,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._store = store
|
|
29
|
+
self._endpoint = endpoint
|
|
30
|
+
self._settings = settings
|
|
31
|
+
self._clock = clock
|
|
32
|
+
self._flight: SingleFlight[bool] = SingleFlight()
|
|
33
|
+
self._listeners: list[Callable[[], None]] = []
|
|
34
|
+
|
|
35
|
+
def current(self) -> Session | None:
|
|
36
|
+
return self._store.load()
|
|
37
|
+
|
|
38
|
+
def begin(self, tokens: Mapping[str, Any]) -> Session:
|
|
39
|
+
session = session_from_login(tokens, self._clock())
|
|
40
|
+
self._store.save(session)
|
|
41
|
+
self._notify_change()
|
|
42
|
+
return session
|
|
43
|
+
|
|
44
|
+
def end(self) -> None:
|
|
45
|
+
self._store.clear()
|
|
46
|
+
self._notify_change()
|
|
47
|
+
|
|
48
|
+
def on_change(self, listener: Callable[[], None]) -> None:
|
|
49
|
+
self._listeners.append(listener)
|
|
50
|
+
|
|
51
|
+
def access_token(self) -> str | None:
|
|
52
|
+
if self._needs_refresh(self.current()):
|
|
53
|
+
self.refresh()
|
|
54
|
+
session = self.current()
|
|
55
|
+
return None if session is None else session.access_token
|
|
56
|
+
|
|
57
|
+
def refresh(self) -> bool:
|
|
58
|
+
return self._flight.run(self._exchange_refresh_token)
|
|
59
|
+
|
|
60
|
+
def _needs_refresh(self, session: Session | None) -> bool:
|
|
61
|
+
return session is not None and session.refresh_token is not None and session.is_stale_at(self._clock())
|
|
62
|
+
|
|
63
|
+
def _exchange_refresh_token(self) -> bool:
|
|
64
|
+
session = self.current()
|
|
65
|
+
if session is None or session.refresh_token is None:
|
|
66
|
+
return False
|
|
67
|
+
try:
|
|
68
|
+
response = self._endpoint.request(self._refresh_form(session.refresh_token))
|
|
69
|
+
except OvokError as error:
|
|
70
|
+
return self._sign_out_if_rejected(error)
|
|
71
|
+
self._store.save(session_after_refresh(session, response, self._clock()))
|
|
72
|
+
self._notify_change()
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def _notify_change(self) -> None:
|
|
76
|
+
for listener in tuple(self._listeners):
|
|
77
|
+
listener()
|
|
78
|
+
|
|
79
|
+
def _sign_out_if_rejected(self, error: OvokError) -> bool:
|
|
80
|
+
"""Only a refused refresh token signs the user out; an outage keeps the session for a later retry."""
|
|
81
|
+
if error.status in REJECTED_REFRESH_STATUSES:
|
|
82
|
+
self.end()
|
|
83
|
+
self._settings.on_unauthenticated(UnauthenticatedReason(status=error.status))
|
|
84
|
+
return False
|
|
85
|
+
raise error
|
|
86
|
+
|
|
87
|
+
def _refresh_form(self, refresh_token: str) -> dict[str, str]:
|
|
88
|
+
client = {} if self._settings.client_id is None else {"client_id": self._settings.client_id}
|
|
89
|
+
return {"grant_type": REFRESH_TOKEN_GRANT, "refresh_token": refresh_token, **client}
|
ovok_sdk/clock.py
ADDED
ovok_sdk/config.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from ovok_sdk.errors import OvokConfigError, OvokErrorDetails
|
|
8
|
+
from ovok_sdk.http.transport import Transport, UrllibTransport
|
|
9
|
+
from ovok_sdk.storage import MemoryStorage, OvokStorage
|
|
10
|
+
from ovok_sdk.values import finite_number
|
|
11
|
+
|
|
12
|
+
DEFAULT_FHIR_PATH = "fhir"
|
|
13
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
14
|
+
DEFAULT_MAX_NETWORK_RETRIES = 2
|
|
15
|
+
ABSOLUTE_HTTP_URL = re.compile(r"https?://[^/\s]+", re.IGNORECASE)
|
|
16
|
+
PATH_SEPARATOR = "/"
|
|
17
|
+
REFRESH_REJECTED = "refresh-rejected"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class MachineClient:
|
|
22
|
+
client_id: str
|
|
23
|
+
client_secret: str = field(repr=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class UnauthenticatedReason:
|
|
28
|
+
status: int
|
|
29
|
+
type: str = REFRESH_REJECTED
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
OnUnauthenticated = Callable[[UnauthenticatedReason], None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class OvokSettings:
|
|
37
|
+
base_url: str
|
|
38
|
+
tenant_code: str
|
|
39
|
+
fhir_path: str
|
|
40
|
+
storage: OvokStorage = field(repr=False)
|
|
41
|
+
transport: Transport = field(repr=False)
|
|
42
|
+
timeout: float
|
|
43
|
+
max_network_retries: int
|
|
44
|
+
client_id: str | None
|
|
45
|
+
machine_client: MachineClient | None
|
|
46
|
+
on_unauthenticated: OnUnauthenticated = field(repr=False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_settings(
|
|
50
|
+
base_url: str,
|
|
51
|
+
tenant_code: str,
|
|
52
|
+
*,
|
|
53
|
+
fhir_path: str = DEFAULT_FHIR_PATH,
|
|
54
|
+
storage: OvokStorage | None = None,
|
|
55
|
+
client_id: str | None = None,
|
|
56
|
+
client_secret: str | None = None,
|
|
57
|
+
transport: Transport | None = None,
|
|
58
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
59
|
+
max_network_retries: int = DEFAULT_MAX_NETWORK_RETRIES,
|
|
60
|
+
on_unauthenticated: OnUnauthenticated | None = None,
|
|
61
|
+
) -> OvokSettings:
|
|
62
|
+
"""Checks every option up front, so a bad one fails at construction, not on the first request."""
|
|
63
|
+
return OvokSettings(
|
|
64
|
+
base_url=_read_base_url(base_url),
|
|
65
|
+
tenant_code=_require_text(tenant_code, "tenant_code"),
|
|
66
|
+
fhir_path=fhir_path.strip(PATH_SEPARATOR),
|
|
67
|
+
storage=storage or MemoryStorage(),
|
|
68
|
+
transport=transport or UrllibTransport(),
|
|
69
|
+
timeout=_require_positive(timeout, "timeout"),
|
|
70
|
+
max_network_retries=_require_count(max_network_retries, "max_network_retries"),
|
|
71
|
+
client_id=_optional_text(client_id, "client_id"),
|
|
72
|
+
machine_client=_read_machine_client(client_id, client_secret),
|
|
73
|
+
on_unauthenticated=on_unauthenticated or _ignore,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ignore(_reason: UnauthenticatedReason) -> None:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _config_error(message: str) -> OvokConfigError:
|
|
82
|
+
return OvokConfigError(OvokErrorDetails(message))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read_base_url(base_url: str) -> str:
|
|
86
|
+
if isinstance(base_url, str) and ABSOLUTE_HTTP_URL.match(base_url):
|
|
87
|
+
return base_url if base_url.endswith(PATH_SEPARATOR) else f"{base_url}{PATH_SEPARATOR}"
|
|
88
|
+
raise _config_error("base_url must be an absolute http(s) URL.")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _read_machine_client(client_id: str | None, client_secret: str | None) -> MachineClient | None:
|
|
92
|
+
if client_secret is None:
|
|
93
|
+
return None
|
|
94
|
+
return MachineClient(_require_text(client_id, "client_id"), _require_text(client_secret, "client_secret"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _optional_text(value: str | None, name: str) -> str | None:
|
|
98
|
+
return None if value is None else _require_text(value, name)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _require_text(value: str | None, name: str) -> str:
|
|
102
|
+
text = value.strip() if isinstance(value, str) else ""
|
|
103
|
+
if text:
|
|
104
|
+
return text
|
|
105
|
+
raise _config_error(f"{name} is required.")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _require_positive(value: float, name: str) -> float:
|
|
109
|
+
number = finite_number(value)
|
|
110
|
+
if number is not None and number > 0:
|
|
111
|
+
return float(number)
|
|
112
|
+
raise _config_error(f"{name} must be a positive number.")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _require_count(value: int, name: str) -> int:
|
|
116
|
+
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
|
117
|
+
return value
|
|
118
|
+
raise _config_error(f"{name} must be a whole number of at least 0.")
|