hearth-sdk 1.0.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.
hearth/__init__.py ADDED
@@ -0,0 +1,113 @@
1
+ """Hearth identity platform Python SDK.
2
+
3
+ Provides HearthClient (auth flows, RBAC predicates), AdminClient
4
+ (user/realm CRUD), mode-aware middleware, and all request/response types.
5
+ """
6
+
7
+ from .client import HearthClient
8
+ from .admin import AdminClient
9
+ from .errors import (
10
+ HearthError,
11
+ HearthSdkError,
12
+ ConfigurationError,
13
+ DiscoveryError,
14
+ JWKSFetchError,
15
+ TokenExpiredError,
16
+ TokenNotYetValidError,
17
+ TokenInvalidError,
18
+ TokenIssuerError,
19
+ TokenAudienceError,
20
+ IntrospectionError,
21
+ RequiredActionError,
22
+ AuthorizationModeMismatchError,
23
+ )
24
+ from .claims import Claims
25
+ from .middleware import RequirePermissionMiddleware, WsgiPermissionMiddleware
26
+ from .types import (
27
+ AccessTokenAuthorizationMode,
28
+ BootstrapResponse,
29
+ User,
30
+ CreateUserRequest,
31
+ UpdateUserRequest,
32
+ Realm,
33
+ CreateRealmRequest,
34
+ UpdateRealmRequest,
35
+ PageResponse,
36
+ AuthorizeResponse,
37
+ TokenResponse,
38
+ UserInfoResponse,
39
+ MePermissionsResponse,
40
+ OAuthClient,
41
+ RegisterClientRequest,
42
+ CreateClientRequest,
43
+ UpdateClientRequest,
44
+ Role,
45
+ CreateRoleRequest,
46
+ UpdateRoleRequest,
47
+ Group,
48
+ CreateGroupRequest,
49
+ UpdateGroupRequest,
50
+ OrgMember,
51
+ AddOrgMemberRequest,
52
+ JwksDocument,
53
+ IntrospectRequest,
54
+ IntrospectResponse,
55
+ CheckPermissionRequest,
56
+ CheckPermissionResponse,
57
+ )
58
+
59
+ __all__ = [
60
+ # Clients
61
+ "HearthClient",
62
+ "AdminClient",
63
+ # Middleware
64
+ "RequirePermissionMiddleware",
65
+ "WsgiPermissionMiddleware",
66
+ # Errors
67
+ "HearthError",
68
+ "HearthSdkError",
69
+ "ConfigurationError",
70
+ "DiscoveryError",
71
+ "JWKSFetchError",
72
+ "TokenExpiredError",
73
+ "TokenNotYetValidError",
74
+ "TokenInvalidError",
75
+ "TokenIssuerError",
76
+ "TokenAudienceError",
77
+ "IntrospectionError",
78
+ "RequiredActionError",
79
+ "AuthorizationModeMismatchError",
80
+ # Claims
81
+ "Claims",
82
+ # Types
83
+ "AccessTokenAuthorizationMode",
84
+ "BootstrapResponse",
85
+ "User",
86
+ "CreateUserRequest",
87
+ "UpdateUserRequest",
88
+ "Realm",
89
+ "CreateRealmRequest",
90
+ "UpdateRealmRequest",
91
+ "PageResponse",
92
+ "AuthorizeResponse",
93
+ "TokenResponse",
94
+ "UserInfoResponse",
95
+ "MePermissionsResponse",
96
+ "OAuthClient",
97
+ "RegisterClientRequest",
98
+ "CreateClientRequest",
99
+ "UpdateClientRequest",
100
+ "Role",
101
+ "CreateRoleRequest",
102
+ "UpdateRoleRequest",
103
+ "Group",
104
+ "CreateGroupRequest",
105
+ "UpdateGroupRequest",
106
+ "OrgMember",
107
+ "AddOrgMemberRequest",
108
+ "JwksDocument",
109
+ "IntrospectRequest",
110
+ "IntrospectResponse",
111
+ "CheckPermissionRequest",
112
+ "CheckPermissionResponse",
113
+ ]
hearth/admin.py ADDED
@@ -0,0 +1,335 @@
1
+ """AdminClient: user and realm CRUD operations (requires admin token)."""
2
+
3
+ from typing import Optional, List
4
+
5
+ import httpx
6
+
7
+ from .errors import HearthError
8
+ from .types import (
9
+ User,
10
+ CreateUserRequest,
11
+ UpdateUserRequest,
12
+ Realm,
13
+ CreateRealmRequest,
14
+ UpdateRealmRequest,
15
+ PageResponse,
16
+ OAuthClient,
17
+ CreateClientRequest,
18
+ UpdateClientRequest,
19
+ Role,
20
+ CreateRoleRequest,
21
+ UpdateRoleRequest,
22
+ Group,
23
+ CreateGroupRequest,
24
+ UpdateGroupRequest,
25
+ OrgMember,
26
+ AddOrgMemberRequest,
27
+ )
28
+
29
+
30
+ class AdminClient:
31
+ """Client for Hearth admin operations (user and realm CRUD).
32
+
33
+ Requires an admin access token obtained via ``/admin/bootstrap`` or
34
+ from a user with the ``hearth.admin`` permission.
35
+
36
+ Attributes:
37
+ base_url: The Hearth server base URL.
38
+ admin_token: A Bearer access token with admin privileges.
39
+ realm_id: The realm to operate on.
40
+ """
41
+
42
+ def __init__(self, base_url: str, admin_token: str, realm_id: str, timeout: float = 30.0):
43
+ self._base = base_url.rstrip("/")
44
+ self._token = admin_token
45
+ self._realm = realm_id
46
+ self._http = httpx.Client(
47
+ headers={
48
+ "X-Realm-ID": realm_id,
49
+ "Authorization": f"Bearer {admin_token}",
50
+ },
51
+ timeout=timeout,
52
+ )
53
+
54
+ # ------------------------------------------------------------------
55
+ # Users
56
+ # ------------------------------------------------------------------
57
+
58
+ def create_user(self, req: CreateUserRequest) -> User:
59
+ """Create a new user."""
60
+ resp = self._http.post(
61
+ f"{self._base}/admin/users", json=req.model_dump(exclude_none=True)
62
+ )
63
+ if resp.status_code not in (200, 201):
64
+ raise HearthError(resp.status_code, resp.text)
65
+ return User(**resp.json())
66
+
67
+ def list_users(
68
+ self, cursor: Optional[str] = None, limit: int = 50
69
+ ) -> PageResponse[User]:
70
+ """List users with cursor-based pagination."""
71
+ params = {"limit": str(limit)}
72
+ if cursor:
73
+ params["cursor"] = cursor
74
+ resp = self._http.get(f"{self._base}/admin/users", params=params)
75
+ if resp.status_code != 200:
76
+ raise HearthError(resp.status_code, resp.text)
77
+ data = resp.json()
78
+ return PageResponse[User](**data)
79
+
80
+ def get_user(self, user_id: str) -> User:
81
+ """Get a user by ID."""
82
+ resp = self._http.get(f"{self._base}/admin/users/{user_id}")
83
+ if resp.status_code != 200:
84
+ raise HearthError(resp.status_code, resp.text)
85
+ return User(**resp.json())
86
+
87
+ def update_user(self, user_id: str, req: UpdateUserRequest) -> User:
88
+ """Update an existing user."""
89
+ resp = self._http.put(
90
+ f"{self._base}/admin/users/{user_id}",
91
+ json=req.model_dump(exclude_none=True),
92
+ )
93
+ if resp.status_code != 200:
94
+ raise HearthError(resp.status_code, resp.text)
95
+ return User(**resp.json())
96
+
97
+ def delete_user(self, user_id: str) -> None:
98
+ """Delete a user."""
99
+ resp = self._http.delete(f"{self._base}/admin/users/{user_id}")
100
+ if resp.status_code not in (200, 204):
101
+ raise HearthError(resp.status_code, resp.text)
102
+
103
+ # ------------------------------------------------------------------
104
+ # Realms
105
+ # ------------------------------------------------------------------
106
+
107
+ def create_realm(self, req: CreateRealmRequest) -> Realm:
108
+ """Create a new realm."""
109
+ resp = self._http.post(
110
+ f"{self._base}/admin/realms", json=req.model_dump(exclude_none=True)
111
+ )
112
+ if resp.status_code not in (200, 201):
113
+ raise HearthError(resp.status_code, resp.text)
114
+ return Realm(**resp.json())
115
+
116
+ def list_realms(self) -> List[Realm]:
117
+ """List all realms."""
118
+ resp = self._http.get(f"{self._base}/admin/realms")
119
+ if resp.status_code != 200:
120
+ raise HearthError(resp.status_code, resp.text)
121
+ data = resp.json()
122
+ return [Realm(**r) for r in data.get("items", data)]
123
+
124
+ def get_realm(self, realm_id: str) -> Realm:
125
+ """Get a realm by ID."""
126
+ resp = self._http.get(f"{self._base}/admin/realms/{realm_id}")
127
+ if resp.status_code != 200:
128
+ raise HearthError(resp.status_code, resp.text)
129
+ return Realm(**resp.json())
130
+
131
+ def update_realm(self, realm_id: str, req: UpdateRealmRequest) -> Realm:
132
+ """Update an existing realm."""
133
+ resp = self._http.put(
134
+ f"{self._base}/admin/realms/{realm_id}",
135
+ json=req.model_dump(exclude_none=True),
136
+ )
137
+ if resp.status_code != 200:
138
+ raise HearthError(resp.status_code, resp.text)
139
+ return Realm(**resp.json())
140
+
141
+ def delete_realm(self, realm_id: str) -> None:
142
+ """Delete a realm."""
143
+ resp = self._http.delete(f"{self._base}/admin/realms/{realm_id}")
144
+ if resp.status_code not in (200, 204):
145
+ raise HearthError(resp.status_code, resp.text)
146
+
147
+ # ------------------------------------------------------------------
148
+ # OAuth Clients
149
+ # ------------------------------------------------------------------
150
+
151
+ def create_client(self, req: CreateClientRequest) -> OAuthClient:
152
+ """Create a new OAuth client."""
153
+ resp = self._http.post(
154
+ f"{self._base}/admin/clients", json=req.model_dump(exclude_none=True)
155
+ )
156
+ if resp.status_code not in (200, 201):
157
+ raise HearthError(resp.status_code, resp.text)
158
+ return OAuthClient(**resp.json())
159
+
160
+ def list_clients(
161
+ self, cursor: Optional[str] = None, limit: int = 50
162
+ ) -> PageResponse[OAuthClient]:
163
+ """List OAuth clients with cursor-based pagination."""
164
+ params = {"limit": str(limit)}
165
+ if cursor:
166
+ params["cursor"] = cursor
167
+ resp = self._http.get(f"{self._base}/admin/clients", params=params)
168
+ if resp.status_code != 200:
169
+ raise HearthError(resp.status_code, resp.text)
170
+ data = resp.json()
171
+ return PageResponse[OAuthClient](**data)
172
+
173
+ def get_client(self, client_id: str) -> OAuthClient:
174
+ """Get an OAuth client by ID."""
175
+ resp = self._http.get(f"{self._base}/admin/clients/{client_id}")
176
+ if resp.status_code != 200:
177
+ raise HearthError(resp.status_code, resp.text)
178
+ return OAuthClient(**resp.json())
179
+
180
+ def update_client(self, client_id: str, req: UpdateClientRequest) -> OAuthClient:
181
+ """Update an existing OAuth client."""
182
+ resp = self._http.put(
183
+ f"{self._base}/admin/clients/{client_id}",
184
+ json=req.model_dump(exclude_none=True),
185
+ )
186
+ if resp.status_code != 200:
187
+ raise HearthError(resp.status_code, resp.text)
188
+ return OAuthClient(**resp.json())
189
+
190
+ def delete_client(self, client_id: str) -> None:
191
+ """Delete an OAuth client."""
192
+ resp = self._http.delete(f"{self._base}/admin/clients/{client_id}")
193
+ if resp.status_code not in (200, 204):
194
+ raise HearthError(resp.status_code, resp.text)
195
+
196
+ # ------------------------------------------------------------------
197
+ # Roles
198
+ # ------------------------------------------------------------------
199
+
200
+ def create_role(self, req: CreateRoleRequest) -> Role:
201
+ """Create a new realm-level role."""
202
+ resp = self._http.post(
203
+ f"{self._base}/admin/roles", json=req.model_dump(exclude_none=True)
204
+ )
205
+ if resp.status_code not in (200, 201):
206
+ raise HearthError(resp.status_code, resp.text)
207
+ return Role(**resp.json())
208
+
209
+ def list_roles(
210
+ self, cursor: Optional[str] = None, limit: int = 50
211
+ ) -> PageResponse[Role]:
212
+ """List realm-level roles with cursor-based pagination."""
213
+ params = {"limit": str(limit)}
214
+ if cursor:
215
+ params["cursor"] = cursor
216
+ resp = self._http.get(f"{self._base}/admin/roles", params=params)
217
+ if resp.status_code != 200:
218
+ raise HearthError(resp.status_code, resp.text)
219
+ data = resp.json()
220
+ return PageResponse[Role](**data)
221
+
222
+ def get_role(self, role_id: str) -> Role:
223
+ """Get a role by ID."""
224
+ resp = self._http.get(f"{self._base}/admin/roles/{role_id}")
225
+ if resp.status_code != 200:
226
+ raise HearthError(resp.status_code, resp.text)
227
+ return Role(**resp.json())
228
+
229
+ def update_role(self, role_id: str, req: UpdateRoleRequest) -> Role:
230
+ """Update an existing role."""
231
+ resp = self._http.put(
232
+ f"{self._base}/admin/roles/{role_id}",
233
+ json=req.model_dump(exclude_none=True),
234
+ )
235
+ if resp.status_code != 200:
236
+ raise HearthError(resp.status_code, resp.text)
237
+ return Role(**resp.json())
238
+
239
+ def delete_role(self, role_id: str) -> None:
240
+ """Delete a role."""
241
+ resp = self._http.delete(f"{self._base}/admin/roles/{role_id}")
242
+ if resp.status_code not in (200, 204):
243
+ raise HearthError(resp.status_code, resp.text)
244
+
245
+ # ------------------------------------------------------------------
246
+ # Groups
247
+ # ------------------------------------------------------------------
248
+
249
+ def create_group(self, req: CreateGroupRequest) -> Group:
250
+ """Create a new realm-level group."""
251
+ resp = self._http.post(
252
+ f"{self._base}/admin/groups", json=req.model_dump(exclude_none=True)
253
+ )
254
+ if resp.status_code not in (200, 201):
255
+ raise HearthError(resp.status_code, resp.text)
256
+ return Group(**resp.json())
257
+
258
+ def list_groups(
259
+ self, cursor: Optional[str] = None, limit: int = 50
260
+ ) -> PageResponse[Group]:
261
+ """List realm-level groups with cursor-based pagination."""
262
+ params = {"limit": str(limit)}
263
+ if cursor:
264
+ params["cursor"] = cursor
265
+ resp = self._http.get(f"{self._base}/admin/groups", params=params)
266
+ if resp.status_code != 200:
267
+ raise HearthError(resp.status_code, resp.text)
268
+ data = resp.json()
269
+ return PageResponse[Group](**data)
270
+
271
+ def get_group(self, group_id: str) -> Group:
272
+ """Get a group by ID."""
273
+ resp = self._http.get(f"{self._base}/admin/groups/{group_id}")
274
+ if resp.status_code != 200:
275
+ raise HearthError(resp.status_code, resp.text)
276
+ return Group(**resp.json())
277
+
278
+ def update_group(self, group_id: str, req: UpdateGroupRequest) -> Group:
279
+ """Update an existing group."""
280
+ resp = self._http.put(
281
+ f"{self._base}/admin/groups/{group_id}",
282
+ json=req.model_dump(exclude_none=True),
283
+ )
284
+ if resp.status_code != 200:
285
+ raise HearthError(resp.status_code, resp.text)
286
+ return Group(**resp.json())
287
+
288
+ def delete_group(self, group_id: str) -> None:
289
+ """Delete a group."""
290
+ resp = self._http.delete(f"{self._base}/admin/groups/{group_id}")
291
+ if resp.status_code not in (200, 204):
292
+ raise HearthError(resp.status_code, resp.text)
293
+
294
+ # ------------------------------------------------------------------
295
+ # Organization Memberships
296
+ # ------------------------------------------------------------------
297
+
298
+ def list_org_members(
299
+ self, org_id: str, cursor: Optional[str] = None, limit: int = 50
300
+ ) -> PageResponse[OrgMember]:
301
+ """List members of an organization with cursor-based pagination."""
302
+ params = {"limit": str(limit)}
303
+ if cursor:
304
+ params["cursor"] = cursor
305
+ resp = self._http.get(f"{self._base}/admin/orgs/{org_id}/members", params=params)
306
+ if resp.status_code != 200:
307
+ raise HearthError(resp.status_code, resp.text)
308
+ data = resp.json()
309
+ return PageResponse[OrgMember](**data)
310
+
311
+ def add_org_member(self, org_id: str, req: AddOrgMemberRequest) -> OrgMember:
312
+ """Add a user to an organization."""
313
+ resp = self._http.post(
314
+ f"{self._base}/admin/orgs/{org_id}/members",
315
+ json=req.model_dump(exclude_none=True),
316
+ )
317
+ if resp.status_code not in (200, 201):
318
+ raise HearthError(resp.status_code, resp.text)
319
+ return OrgMember(**resp.json())
320
+
321
+ def remove_org_member(self, org_id: str, user_id: str) -> None:
322
+ """Remove a user from an organization."""
323
+ resp = self._http.delete(f"{self._base}/admin/orgs/{org_id}/members/{user_id}")
324
+ if resp.status_code not in (200, 204):
325
+ raise HearthError(resp.status_code, resp.text)
326
+
327
+ def close(self):
328
+ """Close the underlying HTTP client."""
329
+ self._http.close()
330
+
331
+ def __enter__(self):
332
+ return self
333
+
334
+ def __exit__(self, *args):
335
+ self.close()
hearth/claims.py ADDED
@@ -0,0 +1,149 @@
1
+ """Spec §4 — Claims API.
2
+
3
+ :class:`Claims` wraps a decoded JWT payload and exposes typed accessors
4
+ for standard OIDC and Hearth-specific claims. All reads are local —
5
+ no network call is made. Signature verification is the caller's
6
+ responsibility.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import time
14
+ from typing import Any, Dict, List, Optional, Union
15
+
16
+ from .errors import (
17
+ TokenExpiredError,
18
+ TokenInvalidError,
19
+ TokenNotYetValidError,
20
+ )
21
+
22
+
23
+ class Claims:
24
+ """Typed accessor for a decoded JWT's claims (spec §4).
25
+
26
+ Construct via :meth:`decode` or pass a pre-decoded payload dict.
27
+ """
28
+
29
+ def __init__(self, payload: Dict[str, Any]) -> None:
30
+ self._payload = payload
31
+
32
+ @classmethod
33
+ def decode(cls, token: str) -> "Claims":
34
+ """Decode a JWT string without verifying its signature.
35
+
36
+ :raises TokenInvalidError: if the string is not a structurally valid JWT.
37
+ """
38
+ parts = token.split(".")
39
+ if len(parts) != 3:
40
+ raise TokenInvalidError("expected three dot-separated segments")
41
+ try:
42
+ padded = parts[1] + "=" * (-len(parts[1]) % 4)
43
+ payload_bytes = base64.urlsafe_b64decode(padded)
44
+ payload: Dict[str, Any] = json.loads(payload_bytes)
45
+ except Exception as exc:
46
+ raise TokenInvalidError(f"failed to decode JWT payload: {exc}") from exc
47
+ return cls(payload)
48
+
49
+ def assert_valid(self, clock_skew_seconds: int = 0) -> None:
50
+ """Assert the token is temporally valid.
51
+
52
+ :raises TokenExpiredError: if exp is in the past.
53
+ :raises TokenNotYetValidError: if nbf is in the future.
54
+ """
55
+ now = int(time.time())
56
+ exp = self._payload.get("exp")
57
+ if exp is not None and now > exp + clock_skew_seconds:
58
+ raise TokenExpiredError(exp)
59
+ nbf = self._payload.get("nbf")
60
+ if nbf is not None and now < nbf - clock_skew_seconds:
61
+ raise TokenNotYetValidError(nbf)
62
+
63
+ def subject(self) -> str:
64
+ """Return the ``sub`` (subject) claim."""
65
+ return str(self._payload.get("sub", ""))
66
+
67
+ def issuer(self) -> str:
68
+ """Return the ``iss`` (issuer) claim."""
69
+ return str(self._payload.get("iss", ""))
70
+
71
+ def audiences(self) -> List[str]:
72
+ """Return the ``aud`` (audiences) claim normalised to a list."""
73
+ aud = self._payload.get("aud")
74
+ if aud is None:
75
+ return []
76
+ if isinstance(aud, list):
77
+ return [str(a) for a in aud]
78
+ return [str(aud)]
79
+
80
+ def expiry(self) -> Optional[int]:
81
+ """Return the ``exp`` claim as a Unix timestamp, or None if absent."""
82
+ val = self._payload.get("exp")
83
+ return int(val) if val is not None else None
84
+
85
+ def issuedAt(self) -> Optional[int]:
86
+ """Return the ``iat`` claim as a Unix timestamp, or None if absent."""
87
+ val = self._payload.get("iat")
88
+ return int(val) if val is not None else None
89
+
90
+ def jwtID(self) -> Optional[str]:
91
+ """Return the ``jti`` (JWT ID) claim, or None if absent."""
92
+ val = self._payload.get("jti")
93
+ return str(val) if val is not None else None
94
+
95
+ def scopes(self) -> List[str]:
96
+ """Return the individual scopes from the ``scope`` claim."""
97
+ raw = self._payload.get("scope", "")
98
+ if not raw:
99
+ return []
100
+ return [s for s in str(raw).split() if s]
101
+
102
+ def hasScope(self, scope: str) -> bool:
103
+ """Return True iff the token contains the given scope."""
104
+ return scope in self.scopes()
105
+
106
+ def scope(self) -> str:
107
+ """Return the ``scope`` claim as a space-delimited string (spec §4)."""
108
+ return str(self._payload.get("scope", ""))
109
+
110
+ def in_group(self, group_id: str) -> bool:
111
+ """Return True iff the token's ``groups`` claim contains the given group."""
112
+ groups: List[str] = self._payload.get("groups", []) or []
113
+ return group_id in groups
114
+
115
+ def in_org(self, org_id: str) -> bool:
116
+ """Return True iff the token's ``oid`` claim matches org_id (exact)."""
117
+ oid = self._payload.get("oid")
118
+ return oid == org_id if oid is not None else False
119
+
120
+ def token_type(self) -> str:
121
+ """Return the ``token_type`` claim (``'access'``, ``'refresh'``, or ``'required_action'``)."""
122
+ return str(self._payload.get("token_type", ""))
123
+
124
+ def organization_id(self) -> Optional[str]:
125
+ """Return the ``oid`` (organization ID) claim, or None if absent."""
126
+ val = self._payload.get("oid")
127
+ return str(val) if val is not None else None
128
+
129
+ def org_groups(self) -> List[str]:
130
+ """Return the ``org_groups`` claim (Keycloak-style paths, e.g. ``/org-slug/group``)."""
131
+ val = self._payload.get("org_groups", []) or []
132
+ return [str(g) for g in val]
133
+
134
+ def hasRole(self, role: str) -> bool:
135
+ """Return True iff the token's ``roles`` claim contains the given role."""
136
+ roles: List[str] = self._payload.get("roles", [])
137
+ return role in roles
138
+
139
+ def hasPermission(self, permission: str) -> bool:
140
+ """Return True iff the token's ``permissions`` claim contains the given permission."""
141
+ permissions: List[str] = self._payload.get("permissions", [])
142
+ return permission in permissions
143
+
144
+ def get(self, key: str) -> Any:
145
+ """Return an arbitrary claim by key."""
146
+ return self._payload.get(key)
147
+
148
+ def __repr__(self) -> str:
149
+ return f"Claims(sub={self.subject()!r}, iss={self.issuer()!r})"