blockauth-sdk 0.5.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.
@@ -0,0 +1,27 @@
1
+ from .errors import BlockAuthError, NotConnectedError
2
+ from .protocol import BlockAuthProvider, RpcClient, WsClient
3
+ from .protocol.roles import (
4
+ get_user_role_names,
5
+ get_user_role_slugs,
6
+ get_user_roles,
7
+ has_any_role,
8
+ has_any_role_by_slug,
9
+ has_role,
10
+ has_role_by_slug,
11
+ )
12
+
13
+ __all__ = [
14
+ "BlockAuthProvider",
15
+ "RpcClient",
16
+ "WsClient",
17
+ "BlockAuthError",
18
+ "NotConnectedError",
19
+ "get_user_roles",
20
+ "get_user_role_names",
21
+ "get_user_role_slugs",
22
+ "has_role",
23
+ "has_role_by_slug",
24
+ "has_any_role",
25
+ "has_any_role_by_slug",
26
+ ]
27
+ __version__ = "0.5.0"
@@ -0,0 +1,45 @@
1
+ import json
2
+ import urllib.error
3
+ import urllib.parse
4
+ import urllib.request
5
+ from typing import Any
6
+
7
+ from .errors import BlockAuthError
8
+
9
+ DEFAULT_WS_URL = "wss://wss.block-auth.io"
10
+
11
+
12
+ def build_ws_server_api_url(path: str, ws_url: str = "") -> str:
13
+ parsed = urllib.parse.urlsplit(ws_url or DEFAULT_WS_URL)
14
+ scheme = "https" if parsed.scheme == "wss" else "http"
15
+ return urllib.parse.urlunsplit((scheme, parsed.netloc, "/api" + path, "", ""))
16
+
17
+
18
+ def create_browser_token(api_key: str, api_secret_hash: str, ws_url: str = "", timeout: float = 10.0) -> str:
19
+ body = json.dumps({"apiKey": api_key, "apiSecret": api_secret_hash}).encode("utf-8")
20
+ request = urllib.request.Request(
21
+ build_ws_server_api_url("/browser-token", ws_url),
22
+ data=body,
23
+ headers={"Content-Type": "application/json"},
24
+ method="POST",
25
+ )
26
+ try:
27
+ with urllib.request.urlopen(request, timeout=timeout) as response:
28
+ payload = _read_json(response)
29
+ except urllib.error.HTTPError as err:
30
+ payload = _read_json(err)
31
+ except (urllib.error.URLError, OSError) as err:
32
+ raise BlockAuthError(f"Browser token request failed: {err}") from err
33
+
34
+ message = payload.get("message")
35
+ if not payload.get("success") or not isinstance(message, dict) or not message.get("accessToken"):
36
+ raise BlockAuthError(str(message or "Failed to create browser token"))
37
+ return message["accessToken"]
38
+
39
+
40
+ def _read_json(response: Any) -> dict:
41
+ try:
42
+ data = json.loads(response.read().decode("utf-8"))
43
+ except (ValueError, OSError):
44
+ return {}
45
+ return data if isinstance(data, dict) else {}
@@ -0,0 +1,9 @@
1
+ import hashlib
2
+
3
+
4
+ def sha256_hex(text: str) -> str:
5
+ """SHA256 del texto (UTF-8) como hex en minúsculas.
6
+
7
+ Equivalente a crypto-js `SHA256(text).toString()` usado por el SDK TS.
8
+ """
9
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
@@ -0,0 +1,9 @@
1
+ class BlockAuthError(Exception):
2
+ """Base error for the BlockAuth SDK."""
3
+
4
+
5
+ class NotConnectedError(BlockAuthError):
6
+ """Raised when an operation needs an active WebSocket connection."""
7
+
8
+ def __init__(self, message: str = "Not connected") -> None:
9
+ super().__init__(message)
@@ -0,0 +1,29 @@
1
+ # Espejo de packages/core/src/protocol del SDK TypeScript (@block-auth.io/core).
2
+ from .provider import BlockAuthProvider
3
+ from .roles import (
4
+ get_user_role_names,
5
+ get_user_role_slugs,
6
+ get_user_roles,
7
+ has_any_role,
8
+ has_any_role_by_slug,
9
+ has_role,
10
+ has_role_by_slug,
11
+ )
12
+ from .rpc_client import RpcClient
13
+ from .transport import SocketIOTransport, Transport
14
+ from .ws_client import WsClient
15
+
16
+ __all__ = [
17
+ "BlockAuthProvider",
18
+ "RpcClient",
19
+ "WsClient",
20
+ "Transport",
21
+ "SocketIOTransport",
22
+ "get_user_roles",
23
+ "get_user_role_names",
24
+ "get_user_role_slugs",
25
+ "has_role",
26
+ "has_role_by_slug",
27
+ "has_any_role",
28
+ "has_any_role_by_slug",
29
+ ]
@@ -0,0 +1,195 @@
1
+ from typing import Any, Optional
2
+
3
+ from ..api_bridge import create_browser_token
4
+ from ..crypto import sha256_hex
5
+ from ..errors import BlockAuthError, NotConnectedError
6
+ from .rpc_client import RpcClient
7
+ from .ws_client import WsClient
8
+ from .roles import (
9
+ get_user_role_names,
10
+ get_user_role_slugs,
11
+ has_any_role,
12
+ has_any_role_by_slug,
13
+ has_role,
14
+ has_role_by_slug,
15
+ )
16
+
17
+
18
+ class BlockAuthProvider:
19
+ def __init__(
20
+ self,
21
+ api_key: str = "",
22
+ api_secret: str = "",
23
+ ws_url: str = "wss://wss.block-auth.io",
24
+ browser_token: str = "",
25
+ transport: Optional[Any] = None,
26
+ ) -> None:
27
+ self._rpc = RpcClient()
28
+ self._wsc = WsClient(ws_url, True, transport=transport)
29
+ self._api_key = api_key
30
+ self._api_secret = api_secret
31
+ self._browser_token = browser_token or ""
32
+ self._ws_url = ws_url
33
+
34
+ # --- browser token ---
35
+ @property
36
+ def browser_token(self) -> str:
37
+ return self._browser_token
38
+
39
+ @browser_token.setter
40
+ def browser_token(self, token: str) -> None:
41
+ self._browser_token = token or ""
42
+
43
+ def create_browser_token(self) -> str:
44
+ if not self._api_secret:
45
+ raise BlockAuthError("API secret required to create a browser token")
46
+ return create_browser_token(self._api_key, sha256_hex(self._api_secret), self._ws_url)
47
+
48
+ # --- conexión ---
49
+ def connect(self, wait_ms: int = 1000, url: str = "", retries: int = 3) -> None:
50
+ self._wsc.connect(url, wait_ms, retries)
51
+
52
+ def disconnect(self) -> None:
53
+ self._wsc.close()
54
+
55
+ def is_connected(self) -> bool:
56
+ return self._wsc.is_connected()
57
+
58
+ def _require_connection(self) -> None:
59
+ if not self.is_connected():
60
+ raise NotConnectedError()
61
+
62
+ # --- métodos del protocolo ---
63
+ def signin(self) -> Any:
64
+ self._require_connection()
65
+ credentials = {"apiKey": self._api_key}
66
+ if self._browser_token:
67
+ credentials["browserToken"] = self._browser_token
68
+ elif self._api_secret:
69
+ credentials["apiSecret"] = sha256_hex(self._api_secret)
70
+ data = self._wsc.request("sdk:signin", "sdk:signin", credentials)
71
+ return {"success": data.get("success"), "message": data.get("message")}
72
+
73
+ def refresh(self, refresh_token: Optional[str] = None) -> Any:
74
+ self._require_connection()
75
+ token = refresh_token or self._wsc.refresh_token
76
+ if not token:
77
+ raise BlockAuthError("No refresh token provided")
78
+ data = self._wsc.request("sdk:refresh", "sdk:refresh", {"refreshToken": token})
79
+ if not data.get("success"):
80
+ raise BlockAuthError(str(data.get("message")))
81
+ return data.get("message")
82
+
83
+ def is_address_signed(self, address: str, token: Optional[str] = None) -> Any:
84
+ self._require_connection()
85
+ _token = token or self._wsc.access_token
86
+ data = self._wsc.request("sdk:sign", "sdk:sign", {"token": _token, "address": address})
87
+ return {"success": data.get("success"), "message": data.get("message")}
88
+
89
+ def app(self, token: Optional[str] = None) -> Any:
90
+ self._require_connection()
91
+ _token = token or self._wsc.access_token
92
+ data = self._wsc.request("sdk:app", "sdk:app", {"token": _token})
93
+ if not data.get("success"):
94
+ raise BlockAuthError(str(data.get("message")))
95
+ return data.get("message")
96
+
97
+ def user(self, address: str, token: Optional[str] = None) -> Any:
98
+ self._require_connection()
99
+ _token = token or self._wsc.access_token
100
+ data = self._wsc.request("sdk:user", "sdk:user", {"token": _token, "address": address})
101
+ return {"success": data.get("success"), "message": data.get("message")}
102
+
103
+ def users(self, token: Optional[str] = None, data: Optional[dict] = None) -> Any:
104
+ self._require_connection()
105
+ _token = token or self._wsc.access_token
106
+ payload = {"token": _token, **(data or {})}
107
+ resp = self._wsc.request("sdk:users", "sdk:users", payload)
108
+ if not resp.get("success"):
109
+ raise BlockAuthError(str(resp.get("message")))
110
+ return resp.get("message")
111
+
112
+ def roles(self, token: Optional[str] = None, data: Optional[dict] = None) -> Any:
113
+ self._require_connection()
114
+ _token = token or self._wsc.access_token
115
+ payload = {"token": _token, **(data or {})}
116
+ resp = self._wsc.request("sdk:roles", "sdk:roles", payload)
117
+ if not resp.get("success"):
118
+ raise BlockAuthError(str(resp.get("message")))
119
+ return resp.get("message")
120
+
121
+ def logs(self, token: Optional[str] = None, data: Optional[dict] = None) -> Any:
122
+ self._require_connection()
123
+ _token = token or self._wsc.access_token
124
+ payload = {"token": _token, **(data or {})}
125
+ resp = self._wsc.request("sdk:logs", "sdk:logs", payload)
126
+ if not resp.get("success"):
127
+ raise BlockAuthError(str(resp.get("message")))
128
+ return resp.get("message")
129
+
130
+ def kyc_issuer(self, address: str, data: Optional[dict] = None, token: Optional[str] = None) -> Any:
131
+ self._require_connection()
132
+ _token = token or self._wsc.access_token
133
+ payload = {"token": _token, "address": address, "details": {**(data or {})}}
134
+ resp = self._wsc.request("sdk:kyc:issuer", "sdk:kyc:issuer", payload)
135
+ if not resp.get("success"):
136
+ raise BlockAuthError(str(resp.get("message")))
137
+ return resp.get("message")
138
+
139
+ # --- getters ---
140
+ @property
141
+ def rpc(self) -> RpcClient:
142
+ return self._rpc
143
+
144
+ @property
145
+ def wsc(self) -> WsClient:
146
+ return self._wsc
147
+
148
+ @property
149
+ def socket(self) -> Any:
150
+ return self._wsc.ws
151
+
152
+ @property
153
+ def ui_config(self) -> dict:
154
+ return self._wsc.ui_config
155
+
156
+ @property
157
+ def app_users(self) -> list:
158
+ return self._wsc.users
159
+
160
+ @property
161
+ def app_roles(self) -> list:
162
+ return self._wsc.roles
163
+
164
+ @property
165
+ def app_logs(self) -> list:
166
+ return self._wsc.logs
167
+
168
+ @property
169
+ def app_kyc_issuer_by_address(self) -> dict:
170
+ return self._wsc.kyc_issuer_by_address
171
+
172
+ @property
173
+ def current_user(self) -> dict:
174
+ return self._wsc.user
175
+
176
+ @property
177
+ def role_names(self) -> list:
178
+ return get_user_role_names(self.current_user)
179
+
180
+ @property
181
+ def role_slugs(self) -> list:
182
+ return get_user_role_slugs(self.current_user)
183
+
184
+ # --- helpers de roles ---
185
+ def has_role(self, role_name: str) -> bool:
186
+ return has_role(self.current_user, role_name)
187
+
188
+ def has_role_by_slug(self, role_slug: str) -> bool:
189
+ return has_role_by_slug(self.current_user, role_slug)
190
+
191
+ def has_any_role(self, role_names: list) -> bool:
192
+ return has_any_role(self.current_user, role_names)
193
+
194
+ def has_any_role_by_slug(self, role_slugs: list) -> bool:
195
+ return has_any_role_by_slug(self.current_user, role_slugs)
@@ -0,0 +1,55 @@
1
+ from typing import Any, Optional
2
+
3
+
4
+ def _normalize(value: Any) -> str:
5
+ return str(value if value is not None else "").strip().lower()
6
+
7
+
8
+ def _is_valid_role(role: Any) -> bool:
9
+ return (
10
+ isinstance(role, dict)
11
+ and isinstance(role.get("name"), str)
12
+ and role["name"].strip() != ""
13
+ and isinstance(role.get("slug"), str)
14
+ and role["slug"].strip() != ""
15
+ )
16
+
17
+
18
+ def get_user_roles(user: Optional[dict]) -> list:
19
+ if not user or not isinstance(user.get("roles"), list):
20
+ return []
21
+ return [role for role in user["roles"] if _is_valid_role(role)]
22
+
23
+
24
+ def get_user_role_names(user: Optional[dict]) -> list:
25
+ return [role["name"] for role in get_user_roles(user)]
26
+
27
+
28
+ def get_user_role_slugs(user: Optional[dict]) -> list:
29
+ return [role["slug"] for role in get_user_roles(user) if role["slug"]]
30
+
31
+
32
+ def has_role(user: Optional[dict], role_name: str) -> bool:
33
+ target = _normalize(role_name)
34
+ if not target:
35
+ return False
36
+ return any(_normalize(name) == target for name in get_user_role_names(user))
37
+
38
+
39
+ def has_role_by_slug(user: Optional[dict], role_slug: str) -> bool:
40
+ target = _normalize(role_slug)
41
+ if not target:
42
+ return False
43
+ return any(_normalize(slug) == target for slug in get_user_role_slugs(user))
44
+
45
+
46
+ def has_any_role(user: Optional[dict], role_names: list) -> bool:
47
+ if not isinstance(role_names, list) or len(role_names) == 0:
48
+ return False
49
+ return any(has_role(user, name) for name in role_names)
50
+
51
+
52
+ def has_any_role_by_slug(user: Optional[dict], role_slugs: list) -> bool:
53
+ if not isinstance(role_slugs, list) or len(role_slugs) == 0:
54
+ return False
55
+ return any(has_role_by_slug(user, slug) for slug in role_slugs)
@@ -0,0 +1,9 @@
1
+ class RpcClient:
2
+ """Stub del cliente RPC (paridad con el TS; hoy solo guarda la URL)."""
3
+
4
+ def __init__(self) -> None:
5
+ self._rpc_url = "https://rpc.block-auth.io/"
6
+
7
+ @property
8
+ def rpc_url(self) -> str:
9
+ return self._rpc_url
@@ -0,0 +1,92 @@
1
+ from typing import Any, Callable, Dict, List, Optional
2
+
3
+ try:
4
+ from typing import Protocol, runtime_checkable
5
+ except ImportError: # pragma: no cover
6
+ from typing_extensions import Protocol, runtime_checkable # type: ignore
7
+
8
+
9
+ @runtime_checkable
10
+ class Transport(Protocol):
11
+ @property
12
+ def connected(self) -> bool: ...
13
+ def connect(self, url: str, config: dict, wait_ms: int = 5000) -> None: ...
14
+ def on(self, event: str, handler: Callable) -> None: ...
15
+ def once(self, event: str, handler: Callable) -> None: ...
16
+ def off(self, event: str, handler: Optional[Callable] = None) -> None: ...
17
+ def emit(self, event: str, data: Any) -> None: ...
18
+ def has_listeners(self, event: str) -> bool: ...
19
+ def disconnect(self) -> None: ...
20
+ def close(self) -> None: ...
21
+
22
+
23
+ class SocketIOTransport:
24
+ """Transporte real sobre python-socketio (Socket.IO v4).
25
+
26
+ python-socketio no ofrece `once` ni `has_listeners`, así que los
27
+ implementamos con un registro propio de listeners.
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ import socketio # import perezoso: solo si se usa el transporte real
32
+
33
+ self._sio = socketio.Client(reconnection=True)
34
+ self._listeners: Dict[str, List[Callable]] = {}
35
+
36
+ @property
37
+ def connected(self) -> bool:
38
+ return bool(getattr(self._sio, "connected", False))
39
+
40
+ def connect(self, url: str, config: dict, wait_ms: int = 5000) -> None:
41
+ transports = config.get("transports", ["websocket"])
42
+ # wait_ms (ms) -> wait_timeout de socketio (segundos, mínimo 1).
43
+ self._sio.connect(
44
+ url,
45
+ transports=transports,
46
+ wait=True,
47
+ wait_timeout=max(1, wait_ms // 1000),
48
+ )
49
+
50
+ def _register(self, event: str) -> None:
51
+ # Registra un único dispatcher en socketio por evento; reenvía a
52
+ # nuestros listeners (permite múltiples handlers + once).
53
+ if event in self._listeners:
54
+ return
55
+ self._listeners[event] = []
56
+
57
+ def dispatcher(data=None):
58
+ for handler in list(self._listeners.get(event, [])):
59
+ handler(data)
60
+
61
+ self._sio.on(event, dispatcher)
62
+
63
+ def on(self, event: str, handler: Callable) -> None:
64
+ self._register(event)
65
+ self._listeners[event].append(handler)
66
+
67
+ def once(self, event: str, handler: Callable) -> None:
68
+ def wrapper(data=None):
69
+ self.off(event, wrapper)
70
+ handler(data)
71
+
72
+ self.on(event, wrapper)
73
+
74
+ def off(self, event: str, handler: Optional[Callable] = None) -> None:
75
+ if event not in self._listeners:
76
+ return
77
+ if handler is None:
78
+ self._listeners[event] = []
79
+ elif handler in self._listeners[event]:
80
+ self._listeners[event].remove(handler)
81
+
82
+ def emit(self, event: str, data: Any) -> None:
83
+ self._sio.emit(event, data)
84
+
85
+ def has_listeners(self, event: str) -> bool:
86
+ return len(self._listeners.get(event, [])) > 0
87
+
88
+ def disconnect(self) -> None:
89
+ self._sio.disconnect()
90
+
91
+ def close(self) -> None:
92
+ self._sio.disconnect()
@@ -0,0 +1,220 @@
1
+ import threading
2
+ from typing import Any, Callable, List, Optional
3
+
4
+ from ..errors import BlockAuthError
5
+ from .transport import SocketIOTransport, Transport
6
+
7
+ DEFAULT_WS_URL = "wss://wss.block-auth.io/"
8
+
9
+
10
+ class WsClient:
11
+ def __init__(
12
+ self,
13
+ url: str = "",
14
+ reconnect: bool = False,
15
+ transport: Optional[Transport] = None,
16
+ ) -> None:
17
+ self._ws_url = url or DEFAULT_WS_URL
18
+ self._ws_config = {
19
+ "transports": ["websocket"],
20
+ "reconnection": reconnect,
21
+ "reconnectionDelay": 100,
22
+ "reconnectionDelayMax": 3000,
23
+ "reconnectionAttempts": 30,
24
+ }
25
+ self._transport: Transport = transport or SocketIOTransport()
26
+ self._handlers_wired = False
27
+ self._access_token = ""
28
+ self._refresh_token = ""
29
+ self._ui_config: dict = {}
30
+ self._user: dict = {}
31
+ self._users: List[Any] = []
32
+ self._roles: List[Any] = []
33
+ self._logs: List[Any] = []
34
+ self._kyc_issuer_by_address: dict = {}
35
+
36
+ # --- conexión ---
37
+ def is_connected(self) -> bool:
38
+ return bool(self._transport) and bool(self._transport.connected)
39
+
40
+ def connect(self, url: str = "", wait_ms: int = 5000, retries: int = 3) -> None:
41
+ target = url or self._ws_url
42
+ attempts = max(1, retries)
43
+ for attempt in range(attempts):
44
+ if self._transport.connected:
45
+ break
46
+ try:
47
+ self._transport.connect(target, self._ws_config, wait_ms)
48
+ except Exception:
49
+ # Reintenta hasta agotar `retries`; en el último intento propaga.
50
+ if attempt >= attempts - 1:
51
+ raise
52
+ continue
53
+ if self._transport.connected:
54
+ break
55
+ self._wire_handlers()
56
+
57
+ def _wire_handlers(self) -> None:
58
+ if self._handlers_wired:
59
+ return
60
+ self._handlers_wired = True
61
+ handlers = {
62
+ "sdk:signin": self.on_sdk_signin,
63
+ "sdk:refresh": self.on_sdk_refresh,
64
+ "sdk:sign": self.on_sdk_sign,
65
+ "sdk:app": self.on_sdk_app,
66
+ "sdk:user": self.on_sdk_user,
67
+ "sdk:users": self.on_sdk_users,
68
+ "sdk:roles": self.on_sdk_roles,
69
+ "sdk:logs": self.on_sdk_logs,
70
+ "sdk:kyc:issuer": self.on_sdk_kyc_issuer,
71
+ }
72
+ for event, handler in handlers.items():
73
+ self._transport.on(event, handler)
74
+
75
+ def on(self, event: str, handler: Callable) -> None:
76
+ self._transport.on(event, handler)
77
+
78
+ def off(self, event: str, handler: Callable) -> None:
79
+ self._transport.off(event, handler)
80
+
81
+ def emit(self, event: str, data: Any) -> None:
82
+ self._transport.emit(event, data)
83
+
84
+ def request(
85
+ self,
86
+ emit_event: str,
87
+ response_event: str,
88
+ payload: dict,
89
+ timeout: float = 10.0,
90
+ ) -> Any:
91
+ holder: dict = {}
92
+ done = threading.Event()
93
+
94
+ # Se registra con `on` (no `once`) para que el callable registrado sea
95
+ # exactamente `handler`: así `off(handler)` lo elimina de verdad tanto en
96
+ # éxito como en timeout. (El `once` del transporte envuelve el handler en
97
+ # un wrapper interno que `off(handler)` no encontraría → fuga de listener.)
98
+ def handler(data=None):
99
+ holder["data"] = data
100
+ self._transport.off(response_event, handler)
101
+ done.set()
102
+
103
+ self._transport.on(response_event, handler)
104
+ self._transport.emit(emit_event, payload)
105
+ if not done.wait(timeout):
106
+ self._transport.off(response_event, handler)
107
+ raise BlockAuthError(f"Timeout waiting for {response_event}")
108
+ return holder.get("data")
109
+
110
+ def close(self) -> None:
111
+ if self.is_connected():
112
+ self._transport.close()
113
+ self._access_token = ""
114
+ self._refresh_token = ""
115
+
116
+ # --- handlers de estado (solo mutan estado; nunca lanzan) ---
117
+ def on_sdk_signin(self, data: Any) -> None:
118
+ if not isinstance(data, dict) or not data.get("success"):
119
+ return
120
+ message = data.get("message") or {}
121
+ self._access_token = message.get("accessToken", self._access_token)
122
+ self._refresh_token = message.get("refreshToken", self._refresh_token)
123
+
124
+ def on_sdk_refresh(self, data: Any) -> None:
125
+ if not isinstance(data, dict) or not data.get("success"):
126
+ return
127
+ message = data.get("message") or {}
128
+ access = message.get("accessToken")
129
+ refresh = message.get("refreshToken")
130
+ # Guardia anti-replay (fiel al TS): solo se actualiza si AMBOS tokens
131
+ # cambian; si cualquiera coincide con el actual, se ignora (no-op).
132
+ if access and refresh and access != self._access_token and refresh != self._refresh_token:
133
+ self._access_token = access
134
+ self._refresh_token = refresh
135
+
136
+ def on_sdk_sign(self, data: Any) -> None:
137
+ if isinstance(data, dict) and not data.get("success") and data.get("message") == "Bad sign":
138
+ self._transport.emit("sdk:refresh", {"refreshToken": self._refresh_token})
139
+
140
+ def on_sdk_app(self, data: Any) -> None:
141
+ if not isinstance(data, dict) or not data.get("success"):
142
+ return
143
+ message = data.get("message") or {}
144
+ config = message.get("config") or {}
145
+ self._ui_config = {
146
+ "domain": message.get("domain"),
147
+ "signinCallbackUrl": message.get("signinCallbackUrl"),
148
+ **config,
149
+ }
150
+
151
+ def on_sdk_user(self, data: Any) -> None:
152
+ if not isinstance(data, dict) or not data.get("success") or not data.get("message"):
153
+ self._user = {}
154
+ return
155
+ message = data["message"]
156
+ items = message.get("items") if isinstance(message, dict) else None
157
+ # `items` solo se usa si es una lista no vacía (un items no-lista, p.ej. dict,
158
+ # haría fallar items[0]); si no, se usa el propio message.
159
+ user = items[0] if isinstance(items, list) and items else message
160
+ # Si el usuario resuelto no es un dict (mensaje malformado), no se propaga
161
+ # ni se lanza: se resetea a {} (los handlers nunca lanzan por control de flujo).
162
+ if not isinstance(user, dict):
163
+ self._user = {}
164
+ return
165
+ roles = user.get("roles")
166
+ self._user = {**user, "roles": roles if isinstance(roles, list) else []}
167
+
168
+ def on_sdk_users(self, data: Any) -> None:
169
+ if isinstance(data, dict) and data.get("success"):
170
+ self._users = list((data.get("message") or {}).get("items", []))
171
+
172
+ def on_sdk_roles(self, data: Any) -> None:
173
+ if isinstance(data, dict) and data.get("success"):
174
+ self._roles = list((data.get("message") or {}).get("items", []))
175
+
176
+ def on_sdk_logs(self, data: Any) -> None:
177
+ if isinstance(data, dict) and data.get("success"):
178
+ self._logs = list((data.get("message") or {}).get("items", []))
179
+
180
+ def on_sdk_kyc_issuer(self, data: Any) -> None:
181
+ if isinstance(data, dict) and data.get("success"):
182
+ items = (data.get("message") or {}).get("items", [])
183
+ self._kyc_issuer_by_address = dict(items[0]) if items else {}
184
+
185
+ # --- propiedades ---
186
+ @property
187
+ def ws(self) -> Transport:
188
+ return self._transport
189
+
190
+ @property
191
+ def access_token(self) -> str:
192
+ return self._access_token
193
+
194
+ @property
195
+ def refresh_token(self) -> str:
196
+ return self._refresh_token
197
+
198
+ @property
199
+ def ui_config(self) -> dict:
200
+ return self._ui_config
201
+
202
+ @property
203
+ def user(self) -> dict:
204
+ return self._user
205
+
206
+ @property
207
+ def users(self) -> list:
208
+ return self._users
209
+
210
+ @property
211
+ def roles(self) -> list:
212
+ return self._roles
213
+
214
+ @property
215
+ def logs(self) -> list:
216
+ return self._logs
217
+
218
+ @property
219
+ def kyc_issuer_by_address(self) -> dict:
220
+ return self._kyc_issuer_by_address
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: blockauth-sdk
3
+ Version: 0.5.0
4
+ Summary: BlockAuth Auth SDK (Python) — blockchain-based authentication client.
5
+ Author: Block-Auth.io Team
6
+ License-Expression: ISC
7
+ Project-URL: Homepage, https://block-auth.io
8
+ Project-URL: Repository, https://gitlab.com/blockauthio/blockauth-sdk-python
9
+ Project-URL: Issues, https://gitlab.com/blockauthio/blockauth-sdk-python/-/issues
10
+ Keywords: blockauth,authentication,blockchain,web3,socket.io,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Security
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: python-socketio[client]<6,>=5.11
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8; extra == "dev"
23
+ Requires-Dist: pytest-cov>=5; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # BlockAuth SDK (Python)
27
+
28
+ Cliente headless del servicio de autenticación BlockAuth (Socket.IO v4).
29
+ Puerto de referencia de la capa de protocolo de `@block-auth.io/core`
30
+ (monorepo `blockauth-sdk`, patrón *headless core + thin per-framework adapters*).
31
+
32
+ ## Arquitectura
33
+
34
+ Este puerto replica la capa `protocol/` del core TypeScript; la reactividad y
35
+ la UI de los bindings (React/Vue/Angular) quedan fuera de alcance.
36
+
37
+ | Este repo | Upstream (`blockauth-sdk`) |
38
+ | :--- | :--- |
39
+ | `blockauth_sdk/protocol/provider.py` | `packages/core/src/protocol/BlockAuthProvider.ts` |
40
+ | `blockauth_sdk/protocol/ws_client.py` | `packages/core/src/protocol/WsClient.ts` |
41
+ | `blockauth_sdk/protocol/rpc_client.py` | `packages/core/src/protocol/RpcClient.ts` |
42
+ | `blockauth_sdk/protocol/roles.py` | `packages/core/src/protocol/roles.ts` |
43
+ | `blockauth_sdk/protocol/transport.py` | — (abstracción propia del puerto; upstream usa socket.io directo) |
44
+ | `blockauth_sdk/api_bridge.py` | `packages/core/src/utils/apiBridge.ts` (solo la emisión del browser token) |
45
+ | `blockauth_sdk/crypto.py`, `blockauth_sdk/errors.py` | — (infra propia del puerto) |
46
+
47
+ La API pública se re-exporta desde la raíz del paquete (`from blockauth_sdk import
48
+ BlockAuthProvider`), igual que hace el `index.ts` del core.
49
+
50
+ ## Instalación
51
+
52
+ ```bash
53
+ pip install blockauth-sdk
54
+ ```
55
+
56
+ ## Uso
57
+
58
+ Un backend se autentica con su `apiKey` y su `apiSecret`. El `apiSecret` solo
59
+ vive en el backend.
60
+
61
+ ```python
62
+ from blockauth_sdk import BlockAuthProvider
63
+
64
+ provider = BlockAuthProvider(api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET")
65
+ provider.connect()
66
+
67
+ result = provider.signin()
68
+ if result["success"]:
69
+ profile = provider.user("0xUserAddress")
70
+ print(provider.role_names)
71
+
72
+ provider.disconnect()
73
+ ```
74
+
75
+ No autentiques un backend con un browser token: el ws-server lo rechaza (exige
76
+ un `Origin` de confianza y su scope no da acceso a `user`, `users`, `roles` ni
77
+ `logs`).
78
+
79
+ ### Login por redirección
80
+
81
+ Para que tus usuarios inicien sesión con BlockAuth, tu backend los manda al
82
+ login alojado y recibe de vuelta un código de un solo uso, que canjea por la
83
+ dirección y los roles del usuario. La dirección la da el ws-server, nunca el
84
+ navegador.
85
+
86
+ ```python
87
+ import json
88
+ import secrets
89
+ import urllib.error
90
+ import urllib.request
91
+
92
+ from blockauth_sdk.api_bridge import build_ws_server_api_url
93
+ from blockauth_sdk.crypto import sha256_hex
94
+
95
+
96
+ def post(path, body):
97
+ request = urllib.request.Request(
98
+ build_ws_server_api_url(path),
99
+ data=json.dumps(body).encode("utf-8"),
100
+ headers={"Content-Type": "application/json"},
101
+ )
102
+ try:
103
+ with urllib.request.urlopen(request) as response:
104
+ data = json.load(response)
105
+ except urllib.error.HTTPError as err: # los errores llegan como 400 con el mismo JSON
106
+ data = json.load(err)
107
+ if not data.get("success"):
108
+ raise RuntimeError(data.get("message"))
109
+ return data["message"]
110
+
111
+
112
+ credentials = {"apiKey": "YOUR_API_KEY", "apiSecret": sha256_hex("YOUR_API_SECRET")}
113
+ redirect_uri = "https://tuapp.com/callback"
114
+
115
+ # 1. Pide el enlace de login y redirige al usuario a él.
116
+ state = secrets.token_hex(16) # guárdalo en la sesión
117
+ link = post("/auth/authorize", {**credentials, "redirectUri": redirect_uri, "state": state})["url"]
118
+
119
+ # 2. El usuario vuelve a redirect_uri?code=...&state=... Comprueba que el state es el de la sesión.
120
+
121
+ # 3. Canjea el código (válido 60 s, un solo uso).
122
+ user = post("/auth/token", {**credentials, "code": code, "redirectUri": redirect_uri})
123
+ address = user["address"] # también: roles, signedExpiredAt
124
+ ```
125
+
126
+ - El `redirect_uri` tiene que ser `https` en el dominio (o el dominio de
127
+ desarrollo) de la app; `http` solo en `localhost`.
128
+ - Para cerrar la sesión del usuario en tu app: `post("/auth/signout",
129
+ {**credentials, "address": address})`.
130
+
131
+ ### Browser token para tu frontend
132
+
133
+ Si tu frontend usa `@block-auth.io/core`, el backend le emite un browser token
134
+ para que el `apiSecret` no salga nunca del backend:
135
+
136
+ ```python
137
+ provider = BlockAuthProvider(api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET")
138
+ browser_token = provider.create_browser_token()
139
+ ```
140
+
141
+ El frontend lo usa como `browserToken` en `@block-auth.io/core`.
142
+
143
+ ## API
144
+
145
+ `BlockAuthProvider`: `connect`, `disconnect`, `is_connected`, `signin`, `create_browser_token`, `refresh`,
146
+ `is_address_signed`, `app`, `user`, `users`, `roles`, `logs`, `kyc_issuer`,
147
+ helpers de roles (`has_role`, `has_role_by_slug`, `has_any_role`,
148
+ `has_any_role_by_slug`) y getters (`current_user`, `role_names`, `ui_config`, …).
149
+
150
+ ## Tests
151
+
152
+ ```bash
153
+ pip install -e ".[dev]"
154
+ pytest
155
+ ```
156
+
157
+ ## Smoke test contra un servidor real
158
+
159
+ Los tests usan un transporte simulado. Para probar el SDK contra el ws-server
160
+ de PRE (transporte real, login por redirección y canje del código), usa la app
161
+ de prueba de [`examples/test_app/`](https://gitlab.com/blockauthio/blockauth-sdk-python/-/tree/main/examples/test_app).
@@ -0,0 +1,15 @@
1
+ blockauth_sdk/__init__.py,sha256=eJEHy0xkg0Jb3H8vUu8jEHjXzJQTQke0sDxFmxlt20I,603
2
+ blockauth_sdk/api_bridge.py,sha256=Gc5os7anyrPaZFm8RoM1Vkcb114qc-LVJDcIPLLcKqk,1644
3
+ blockauth_sdk/crypto.py,sha256=IMc-xCQ7pUv42sTDw83jThKckwQCpdNa7448QxR4lA0,252
4
+ blockauth_sdk/errors.py,sha256=oqDMg7QWZSp8_sbrBck10dYpqv2aavHZZxO3gp--q6Q,292
5
+ blockauth_sdk/protocol/__init__.py,sha256=dbUSL8v46WwfpXOFpA0GGbv33TlJV5vbykoZPAkF744,691
6
+ blockauth_sdk/protocol/provider.py,sha256=XDo2Xg9F0BEp3BfH_ravBSRnJoBMvVTb4DerOdlO7rU,6958
7
+ blockauth_sdk/protocol/roles.py,sha256=VIh5dSsAp-a_lCXUUWa7zvYfYdlxCERGf2_Dr8LSZAI,1732
8
+ blockauth_sdk/protocol/rpc_client.py,sha256=txDuuv92x3w9yO6wsQBsE3QhlxRmsaVYPepR0dBldqY,253
9
+ blockauth_sdk/protocol/transport.py,sha256=H84yps5kplv3ZciqEGtyrEbh-tZJsjHxrndLt2yiG78,3116
10
+ blockauth_sdk/protocol/ws_client.py,sha256=tM8gYEtPapPK4N4SkW8jYO74BjSdssRksUKpbcBAgrE,8009
11
+ blockauth_sdk-0.5.0.dist-info/licenses/LICENSE,sha256=mXOK634s7KQHauR30-L3jKdDt6dF2MTQUQAklLpSbmU,757
12
+ blockauth_sdk-0.5.0.dist-info/METADATA,sha256=XWVrk717r05CX4NqQZkjOf8oJfge6UD9XkQXSMuknMc,5899
13
+ blockauth_sdk-0.5.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
14
+ blockauth_sdk-0.5.0.dist-info/top_level.txt,sha256=300jLVbaYrygxtD8YWV8RpjDR3pmNeUKCk-RqPMbvk4,14
15
+ blockauth_sdk-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Block-Auth (block-auth.io)
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1 @@
1
+ blockauth_sdk