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 +113 -0
- hearth/admin.py +335 -0
- hearth/claims.py +149 -0
- hearth/client.py +379 -0
- hearth/errors.py +127 -0
- hearth/middleware.py +346 -0
- hearth/types.py +251 -0
- hearth_sdk-1.0.0.dist-info/METADATA +11 -0
- hearth_sdk-1.0.0.dist-info/RECORD +10 -0
- hearth_sdk-1.0.0.dist-info/WHEEL +4 -0
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})"
|