auths-python 0.1.0__cp38-abi3-win_amd64.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.
auths/jwt.py ADDED
@@ -0,0 +1,253 @@
1
+ """JWT validation for Auths OIDC tokens.
2
+
3
+ Requires PyJWT: pip install auths-python[jwt]
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from dataclasses import dataclass
10
+
11
+ logger = logging.getLogger("auths.jwt")
12
+
13
+
14
+ @dataclass
15
+ class AuthsClaims:
16
+ """Validated claims from an Auths OIDC token."""
17
+
18
+ sub: str
19
+ """Subject claim — the signer's DID."""
20
+ keri_prefix: str
21
+ """KERI prefix of the identity."""
22
+ capabilities: list[str]
23
+ """Capabilities granted by this token."""
24
+ iss: str
25
+ """Issuer claim — the OIDC bridge URL."""
26
+ aud: str
27
+ """Audience claim — the service this token is intended for."""
28
+ exp: int
29
+ """Expiration time as Unix timestamp."""
30
+ iat: int
31
+ """Issued-at time as Unix timestamp."""
32
+ jti: str
33
+ """Unique JWT ID for replay prevention."""
34
+ signer_type: str | None = None
35
+ """Signer classification: `"Human"`, `"Agent"`, or `"Workload"`."""
36
+ delegated_by: str | None = None
37
+ """DID of the delegating identity, if this is a delegated token."""
38
+ witness_quorum: dict | None = None
39
+ """Witness quorum metadata, if witness-backed."""
40
+ github_actor: str | None = None
41
+ """GitHub username, present for GitHub Actions OIDC tokens."""
42
+ github_repository: str | None = None
43
+ """GitHub repository (owner/repo), present for GitHub Actions OIDC tokens."""
44
+
45
+ def has_capability(self, cap: str) -> bool:
46
+ """Check if token grants a specific capability."""
47
+ return cap in self.capabilities
48
+
49
+ def has_any_capability(self, caps: list[str]) -> bool:
50
+ """Check if token grants any of the listed capabilities."""
51
+ return any(c in self.capabilities for c in caps)
52
+
53
+ def has_all_capabilities(self, caps: list[str]) -> bool:
54
+ """Check if token grants all of the listed capabilities."""
55
+ return all(c in self.capabilities for c in caps)
56
+
57
+ @property
58
+ def is_agent(self) -> bool:
59
+ return self.signer_type == "Agent"
60
+
61
+ @property
62
+ def is_human(self) -> bool:
63
+ return self.signer_type == "Human"
64
+
65
+ @property
66
+ def is_delegated(self) -> bool:
67
+ return self.delegated_by is not None
68
+
69
+ def __repr__(self) -> str:
70
+ caps = ", ".join(self.capabilities[:3])
71
+ if len(self.capabilities) > 3:
72
+ caps += f" +{len(self.capabilities) - 3}"
73
+ sub_short = self.sub[:25] if len(self.sub) > 25 else self.sub
74
+ return (
75
+ f"AuthsClaims(sub='{sub_short}...', "
76
+ f"caps=[{caps}], type={self.signer_type})"
77
+ )
78
+
79
+
80
+ class AuthsJWKSClient:
81
+ """JWKS client with automatic key caching for Auths OIDC token validation.
82
+
83
+ Args:
84
+ jwks_url: The OIDC bridge's JWKS endpoint.
85
+ cache_ttl: How long to cache JWKS keys, in seconds (default: 300).
86
+
87
+ Examples:
88
+ ```python
89
+ jwks = AuthsJWKSClient("https://bridge.example.com/.well-known/jwks.json")
90
+ claims = jwks.verify_token(token, audience="my-service")
91
+ ```
92
+ """
93
+
94
+ def __init__(self, jwks_url: str, *, cache_ttl: int = 300):
95
+ try:
96
+ import jwt as pyjwt
97
+ from jwt import PyJWKClient
98
+ except ImportError:
99
+ raise ImportError(
100
+ "PyJWT is required for JWT validation. "
101
+ "Install it with: pip install auths-python[jwt]"
102
+ )
103
+
104
+ from auths._errors import NetworkError
105
+
106
+ self._pyjwt = pyjwt
107
+ self._NetworkError = NetworkError
108
+ self._client = PyJWKClient(jwks_url, cache_jwk_set=True, lifespan=cache_ttl)
109
+ self._jwks_url = jwks_url
110
+ logger.debug("Initialized JWKS client for %s (cache_ttl=%ds)", jwks_url, cache_ttl)
111
+
112
+ def verify_token(
113
+ self,
114
+ token: str,
115
+ *,
116
+ audience: str,
117
+ issuer: str | None = None,
118
+ leeway: int = 60,
119
+ ) -> AuthsClaims:
120
+ """Verify an Auths OIDC token and extract claims.
121
+
122
+ Args:
123
+ token: Raw JWT bearer token string.
124
+ audience: Expected audience claim.
125
+ issuer: Expected issuer claim (optional, verified if set).
126
+ leeway: Clock skew tolerance in seconds (default: 60).
127
+
128
+ Returns:
129
+ AuthsClaims with the validated token claims.
130
+
131
+ Raises:
132
+ VerificationError: If the token is expired, has wrong audience/issuer, or invalid signature.
133
+ NetworkError: If JWKS keys cannot be fetched.
134
+
135
+ Examples:
136
+ ```python
137
+ claims = jwks.verify_token(bearer_token, audience="my-service")
138
+ if claims.has_capability("read"):
139
+ allow_access()
140
+ ```
141
+ """
142
+ from auths._errors import VerificationError
143
+
144
+ try:
145
+ signing_key = self._client.get_signing_key_from_jwt(token)
146
+ except Exception as e:
147
+ logger.warning("JWKS fetch failed for %s: %s", self._jwks_url, e)
148
+ raise self._NetworkError(
149
+ f"Failed to fetch JWKS from {self._jwks_url}: {e}",
150
+ code="jwks_fetch_failed",
151
+ should_retry=True,
152
+ )
153
+
154
+ try:
155
+ options = {"verify_aud": True, "verify_exp": True}
156
+ if issuer:
157
+ options["verify_iss"] = True
158
+
159
+ decoded = self._pyjwt.decode(
160
+ token,
161
+ signing_key.key,
162
+ algorithms=["RS256", "ES256", "EdDSA"],
163
+ audience=audience,
164
+ issuer=issuer,
165
+ leeway=leeway,
166
+ options=options,
167
+ )
168
+ except self._pyjwt.ExpiredSignatureError:
169
+ raise VerificationError(
170
+ "Token expired. Request a new token from the OIDC bridge.",
171
+ code="token_expired",
172
+ )
173
+ except self._pyjwt.InvalidAudienceError:
174
+ raise VerificationError(
175
+ f"Token audience does not match expected '{audience}'. "
176
+ "Ensure the token was issued for this service.",
177
+ code="invalid_audience",
178
+ )
179
+ except self._pyjwt.InvalidIssuerError:
180
+ raise VerificationError(
181
+ f"Token issuer does not match expected '{issuer}'. "
182
+ "Check the OIDC bridge URL configuration.",
183
+ code="invalid_issuer",
184
+ )
185
+ except self._pyjwt.InvalidSignatureError:
186
+ raise VerificationError(
187
+ "Token signature is invalid. The token may have been tampered with "
188
+ "or the JWKS keys may have rotated. Try refreshing the JWKS cache.",
189
+ code="invalid_signature",
190
+ )
191
+ except self._pyjwt.DecodeError as e:
192
+ raise VerificationError(
193
+ f"Token decode failed: {e}. Ensure the token is a valid JWT string.",
194
+ code="decode_error",
195
+ )
196
+
197
+ logger.debug(
198
+ "Token verified: sub=%s, caps=%s",
199
+ decoded.get("sub"),
200
+ decoded.get("capabilities"),
201
+ )
202
+
203
+ return AuthsClaims(
204
+ sub=decoded["sub"],
205
+ keri_prefix=decoded.get("keri_prefix", ""),
206
+ capabilities=decoded.get("capabilities", []),
207
+ iss=decoded.get("iss", ""),
208
+ aud=decoded.get("aud", ""),
209
+ exp=decoded.get("exp", 0),
210
+ iat=decoded.get("iat", 0),
211
+ jti=decoded.get("jti", ""),
212
+ signer_type=decoded.get("signer_type"),
213
+ delegated_by=decoded.get("delegated_by"),
214
+ witness_quorum=decoded.get("witness_quorum"),
215
+ github_actor=decoded.get("github_actor"),
216
+ github_repository=decoded.get("github_repository"),
217
+ )
218
+
219
+
220
+ def verify_token(
221
+ token: str,
222
+ *,
223
+ jwks_url: str,
224
+ audience: str,
225
+ issuer: str | None = None,
226
+ leeway: int = 60,
227
+ ) -> AuthsClaims:
228
+ """Verify an Auths OIDC token (one-shot, no JWKS caching).
229
+
230
+ For production use, prefer `AuthsJWKSClient` which caches JWKS keys.
231
+
232
+ Args:
233
+ token: Raw JWT bearer token string.
234
+ jwks_url: URL to fetch JSON Web Key Set.
235
+ audience: Expected audience claim.
236
+ issuer: Expected issuer claim (optional).
237
+ leeway: Clock skew tolerance in seconds (default: 60).
238
+
239
+ Returns:
240
+ AuthsClaims with the validated token claims.
241
+
242
+ Raises:
243
+ VerificationError: If the token is invalid.
244
+ NetworkError: If JWKS keys cannot be fetched.
245
+
246
+ Examples:
247
+ ```python
248
+ from auths.jwt import verify_token
249
+ claims = verify_token(token, jwks_url="...", audience="my-service")
250
+ ```
251
+ """
252
+ client = AuthsJWKSClient(jwks_url)
253
+ return client.verify_token(token, audience=audience, issuer=issuer, leeway=leeway)
auths/org.py ADDED
@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+ from auths._native import (
8
+ add_org_member as _add_org_member,
9
+ create_org as _create_org,
10
+ list_org_members as _list_org_members,
11
+ revoke_org_member as _revoke_org_member,
12
+ )
13
+ from auths._client import _map_error
14
+ from auths._errors import OrgError
15
+
16
+
17
+ @dataclass
18
+ class Org:
19
+ """An organization identity."""
20
+
21
+ prefix: str
22
+ """KERI prefix of the organization identity."""
23
+ did: str
24
+ """The organization's DID (`did:keri:...`)."""
25
+ label: str
26
+ """Human-readable organization name."""
27
+ repo_path: str
28
+ """Path to the identity repository."""
29
+
30
+ def __repr__(self):
31
+ return f"Org(did={self.did!r}, label={self.label!r})"
32
+
33
+
34
+ @dataclass
35
+ class OrgMember:
36
+ """A member within an organization."""
37
+
38
+ member_did: str
39
+ """DID of the member."""
40
+ role: str
41
+ """Member role: `"admin"`, `"member"`, or `"readonly"`."""
42
+ capabilities: list[str]
43
+ """Capabilities granted to this member."""
44
+ issuer_did: str
45
+ """DID of the identity that issued the membership attestation."""
46
+ attestation_rid: str
47
+ """RID of the membership attestation."""
48
+ revoked: bool
49
+ """Whether this membership has been revoked."""
50
+ expires_at: Optional[str]
51
+ """ISO 8601 expiry timestamp, or None for non-expiring memberships."""
52
+
53
+ @property
54
+ def is_admin(self) -> bool:
55
+ """Whether this member has admin role."""
56
+ return self.role == "admin"
57
+
58
+ def __repr__(self):
59
+ status = " revoked" if self.revoked else ""
60
+ return f"OrgMember(did={self.member_did!r}, role={self.role!r}{status})"
61
+
62
+
63
+ class OrgService:
64
+ """Resource service for organization operations."""
65
+
66
+ def __init__(self, client):
67
+ self._client = client
68
+
69
+ def create(
70
+ self,
71
+ label: str,
72
+ repo_path: str | None = None,
73
+ passphrase: str | None = None,
74
+ ) -> Org:
75
+ """Create a new organization identity.
76
+
77
+ Args:
78
+ label: Human-readable name for the org.
79
+ repo_path: Override identity store path.
80
+ passphrase: Override passphrase.
81
+
82
+ Returns:
83
+ Org with the KERI prefix, DID, and label.
84
+
85
+ Raises:
86
+ OrgError: If organization creation fails.
87
+ KeychainError: If the keychain is locked or inaccessible.
88
+
89
+ Examples:
90
+ ```python
91
+ org = client.orgs.create("my-team")
92
+ ```
93
+ """
94
+ rp = repo_path or self._client.repo_path
95
+ pp = passphrase or self._client._passphrase
96
+ try:
97
+ prefix, did, lbl, rpath = _create_org(label, rp, pp)
98
+ return Org(prefix=prefix, did=did, label=lbl, repo_path=rpath)
99
+ except (ValueError, RuntimeError) as exc:
100
+ raise _map_error(exc, default_cls=OrgError) from exc
101
+
102
+ def add_member(
103
+ self,
104
+ org_did: str,
105
+ member_did: str,
106
+ role: str = "member",
107
+ capabilities: list[str] | None = None,
108
+ note: str | None = None,
109
+ repo_path: str | None = None,
110
+ passphrase: str | None = None,
111
+ member_public_key_hex: str | None = None,
112
+ ) -> OrgMember:
113
+ """Add a member to an organization.
114
+
115
+ Args:
116
+ org_did: The organization's DID (`did:keri:...`).
117
+ member_did: The member's DID to add.
118
+ role: One of `"admin"`, `"member"`, `"readonly"`.
119
+ capabilities: Explicit capability list. If None, uses role defaults.
120
+ note: Optional human-readable note for the attestation.
121
+ member_public_key_hex: Member's Ed25519 public key hex. Required when
122
+ the member's identity is in a different registry.
123
+
124
+ Returns:
125
+ OrgMember with the membership attestation details.
126
+
127
+ Raises:
128
+ OrgError: If the member cannot be added.
129
+
130
+ Examples:
131
+ ```python
132
+ member = client.orgs.add_member(org.did, dev.did, role="member")
133
+ ```
134
+ """
135
+ rp = repo_path or self._client.repo_path
136
+ pp = passphrase or self._client._passphrase
137
+ caps_json = json.dumps(capabilities) if capabilities else None
138
+ try:
139
+ m_did, r, caps_str, issuer, rid, revoked, expires = _add_org_member(
140
+ org_did, member_did, role, rp, caps_json, pp, note,
141
+ member_public_key_hex,
142
+ )
143
+ return OrgMember(
144
+ member_did=m_did,
145
+ role=r,
146
+ capabilities=json.loads(caps_str) if caps_str else [],
147
+ issuer_did=issuer,
148
+ attestation_rid=rid,
149
+ revoked=revoked,
150
+ expires_at=expires,
151
+ )
152
+ except (ValueError, RuntimeError) as exc:
153
+ raise _map_error(exc, default_cls=OrgError) from exc
154
+
155
+ def revoke_member(
156
+ self,
157
+ org_did: str,
158
+ member_did: str,
159
+ note: str | None = None,
160
+ repo_path: str | None = None,
161
+ passphrase: str | None = None,
162
+ member_public_key_hex: str | None = None,
163
+ ) -> OrgMember:
164
+ """Revoke a member's authorization.
165
+
166
+ Args:
167
+ org_did: The organization's DID.
168
+ member_did: The member's DID to revoke.
169
+ note: Optional human-readable note.
170
+ member_public_key_hex: Member's Ed25519 public key hex. Required when
171
+ the member's identity is in a different registry.
172
+
173
+ Returns:
174
+ OrgMember with revoked status.
175
+
176
+ Raises:
177
+ OrgError: If the member cannot be revoked.
178
+
179
+ Examples:
180
+ ```python
181
+ revoked = client.orgs.revoke_member(org.did, dev.did)
182
+ ```
183
+ """
184
+ rp = repo_path or self._client.repo_path
185
+ pp = passphrase or self._client._passphrase
186
+ try:
187
+ m_did, r, caps_str, issuer, rid, revoked, expires = _revoke_org_member(
188
+ org_did, member_did, rp, pp, note, member_public_key_hex,
189
+ )
190
+ return OrgMember(
191
+ member_did=m_did,
192
+ role=r,
193
+ capabilities=json.loads(caps_str) if caps_str else [],
194
+ issuer_did=issuer,
195
+ attestation_rid=rid,
196
+ revoked=revoked,
197
+ expires_at=expires,
198
+ )
199
+ except (ValueError, RuntimeError) as exc:
200
+ raise _map_error(exc, default_cls=OrgError) from exc
201
+
202
+ def update_member(
203
+ self,
204
+ org_did: str,
205
+ member_did: str,
206
+ role: str | None = None,
207
+ capabilities: list[str] | None = None,
208
+ note: str | None = None,
209
+ repo_path: str | None = None,
210
+ passphrase: str | None = None,
211
+ member_public_key_hex: str | None = None,
212
+ ) -> OrgMember:
213
+ """Update a member's role or capabilities.
214
+
215
+ Args:
216
+ org_did: The organization's DID.
217
+ member_did: The member's DID to update.
218
+ role: New role. If None, keeps current.
219
+ capabilities: New capabilities. If None, uses role defaults.
220
+ note: Optional note.
221
+ member_public_key_hex: Member's Ed25519 public key hex. Required when
222
+ the member's identity is in a different registry.
223
+
224
+ Returns:
225
+ OrgMember with the updated role and capabilities.
226
+
227
+ Raises:
228
+ OrgError: If the member cannot be updated.
229
+
230
+ Examples:
231
+ ```python
232
+ updated = client.orgs.update_member(org.did, dev.did, role="admin")
233
+ ```
234
+ """
235
+ self.revoke_member(
236
+ org_did, member_did, note="superseded by update",
237
+ repo_path=repo_path, passphrase=passphrase,
238
+ member_public_key_hex=member_public_key_hex,
239
+ )
240
+ return self.add_member(
241
+ org_did, member_did, role=role or "member",
242
+ capabilities=capabilities, note=note,
243
+ repo_path=repo_path, passphrase=passphrase,
244
+ member_public_key_hex=member_public_key_hex,
245
+ )
246
+
247
+ def list_members(
248
+ self,
249
+ org_did: str,
250
+ include_revoked: bool = False,
251
+ repo_path: str | None = None,
252
+ ) -> list[OrgMember]:
253
+ """List all members of an organization.
254
+
255
+ Args:
256
+ org_did: The organization's DID.
257
+ include_revoked: If True, includes revoked members.
258
+
259
+ Returns:
260
+ List of OrgMember objects.
261
+
262
+ Raises:
263
+ OrgError: If the organization doesn't exist.
264
+
265
+ Examples:
266
+ ```python
267
+ members = client.orgs.list_members(org.did)
268
+ ```
269
+ """
270
+ rp = repo_path or self._client.repo_path
271
+ try:
272
+ members_json = _list_org_members(org_did, include_revoked, rp)
273
+ raw = json.loads(members_json)
274
+ return [
275
+ OrgMember(
276
+ member_did=m["member_did"],
277
+ role=m["role"],
278
+ capabilities=m["capabilities"],
279
+ issuer_did=m["issuer_did"],
280
+ attestation_rid=m["attestation_rid"],
281
+ revoked=m["revoked"],
282
+ expires_at=m.get("expires_at"),
283
+ )
284
+ for m in raw
285
+ ]
286
+ except (ValueError, RuntimeError) as exc:
287
+ raise _map_error(exc, default_cls=OrgError) from exc
288
+
289
+ def get_member(
290
+ self,
291
+ org_did: str,
292
+ member_did: str,
293
+ repo_path: str | None = None,
294
+ ) -> OrgMember | None:
295
+ """Look up a specific member.
296
+
297
+ Args:
298
+ org_did: The organization's DID.
299
+ member_did: The member's DID to look up.
300
+
301
+ Returns:
302
+ OrgMember if found, or None.
303
+
304
+ Examples:
305
+ ```python
306
+ member = client.orgs.get_member(org.did, dev.did)
307
+ ```
308
+ """
309
+ members = self.list_members(org_did, include_revoked=False, repo_path=repo_path)
310
+ return next((m for m in members if m.member_did == member_did), None)