cred-auth 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.
cred/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """Cred — credential delegation SDK for AI agents."""
2
+
3
+ from .client import AsyncCred, Cred
4
+ from .exceptions import (
5
+ AgentRevokedException,
6
+ ConsentRequiredError,
7
+ CredError,
8
+ RateLimitException,
9
+ ScopeCeilingException,
10
+ )
11
+ from .identity import (
12
+ AgentIdentity,
13
+ CRED_PUBLIC_KEY_HEX,
14
+ generate_agent_identity,
15
+ verify_delegation_receipt,
16
+ )
17
+ from .models import DelegationResult, Connection
18
+
19
+ __all__ = [
20
+ "Cred",
21
+ "AsyncCred",
22
+ "CredError",
23
+ "ConsentRequiredError",
24
+ "AgentRevokedException",
25
+ "ScopeCeilingException",
26
+ "RateLimitException",
27
+ "AgentIdentity",
28
+ "generate_agent_identity",
29
+ "verify_delegation_receipt",
30
+ "CRED_PUBLIC_KEY_HEX",
31
+ "DelegationResult",
32
+ "Connection",
33
+ ]
cred/client.py ADDED
@@ -0,0 +1,335 @@
1
+ """Cred SDK — Main client classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any, Optional
7
+ from urllib.parse import urlencode, urlparse
8
+
9
+ import httpx
10
+
11
+ from .exceptions import (
12
+ AgentRevokedException,
13
+ ConsentRequiredError,
14
+ CredError,
15
+ RateLimitException,
16
+ ScopeCeilingException,
17
+ )
18
+ from .models import Connection, DelegationResult
19
+
20
+ DEFAULT_BASE_URL = "https://api.cred.ninja"
21
+ _LOCALHOSTS = {"localhost", "127.0.0.1", "::1"}
22
+
23
+
24
+ def _parse_expires_at(raw_value: Any, expires_in: int) -> datetime:
25
+ if isinstance(raw_value, str):
26
+ parsed_value = raw_value.replace("Z", "+00:00")
27
+ try:
28
+ return datetime.fromisoformat(parsed_value)
29
+ except ValueError:
30
+ pass
31
+ return datetime.now(timezone.utc) + timedelta(seconds=expires_in)
32
+
33
+
34
+ class _CredBase:
35
+ def __init__(self, agent_token: str, base_url: str = DEFAULT_BASE_URL) -> None:
36
+ if not agent_token:
37
+ raise CredError("agent_token is required", "invalid_config", 0)
38
+ self._agent_token = agent_token
39
+ self._base_url = self._validate_base_url(base_url)
40
+
41
+ @staticmethod
42
+ def _validate_base_url(url: str) -> str:
43
+ try:
44
+ parsed = urlparse(url)
45
+ except Exception:
46
+ raise CredError(f'Invalid base_url: "{url}" — must be a valid HTTPS URL', "invalid_config", 0)
47
+
48
+ if parsed.scheme == "https":
49
+ return url.rstrip("/")
50
+
51
+ if parsed.scheme == "http" and parsed.hostname in _LOCALHOSTS:
52
+ return url.rstrip("/")
53
+
54
+ raise CredError(
55
+ "Invalid base_url: must use HTTPS except for localhost development URLs",
56
+ "invalid_config",
57
+ 0,
58
+ )
59
+
60
+ def _auth_headers(self) -> dict[str, str]:
61
+ return {
62
+ "Authorization": f"Bearer {self._agent_token}",
63
+ "Content-Type": "application/json",
64
+ "Accept": "application/json",
65
+ }
66
+
67
+ @staticmethod
68
+ def _build_delegation_result(data: dict[str, Any]) -> DelegationResult:
69
+ raw_expires_in = data.get("expires_in")
70
+ expires_in = int(raw_expires_in) if raw_expires_in is not None else 900
71
+ expires_at = _parse_expires_at(data.get("expires_at") or data.get("expiresAt"), expires_in)
72
+ return DelegationResult(
73
+ access_token=data["access_token"],
74
+ token_type=data["token_type"],
75
+ expires_in=expires_in,
76
+ expires_at=expires_at,
77
+ service=data["service"],
78
+ scopes=data["scopes"],
79
+ delegation_id=data["delegation_id"],
80
+ receipt=data.get("receipt"),
81
+ )
82
+
83
+ @staticmethod
84
+ def _build_connections(data: dict[str, Any]) -> list[Connection]:
85
+ connections = data.get("connections", [])
86
+ return [
87
+ Connection(
88
+ slug=connection["slug"],
89
+ scopes_granted=connection["scopesGranted"],
90
+ app_client_id=connection.get("appClientId"),
91
+ consented_at=connection.get("consentedAt"),
92
+ )
93
+ for connection in connections
94
+ ]
95
+
96
+ @staticmethod
97
+ def _error_payload(response: httpx.Response) -> tuple[dict[str, Any], str, str]:
98
+ body: dict[str, Any] = {}
99
+ try:
100
+ parsed = response.json()
101
+ if isinstance(parsed, dict):
102
+ body = parsed
103
+ except Exception:
104
+ pass
105
+
106
+ message = body.get("message") or body.get("error") or f"Request failed with status {response.status_code}"
107
+ error_code = str(body.get("error", "unknown"))
108
+ return body, str(message), error_code
109
+
110
+ def _handle_response(self, response: httpx.Response) -> Any:
111
+ if response.status_code == 204:
112
+ return {}
113
+
114
+ if response.is_success:
115
+ return response.json()
116
+
117
+ body, message, error_code = self._error_payload(response)
118
+
119
+ if response.status_code == 403 and error_code == "consent_required":
120
+ raise ConsentRequiredError(message, str(body.get("consent_url", "")))
121
+ if response.status_code == 403 and error_code == "agent_revoked":
122
+ raise AgentRevokedException(message)
123
+ if response.status_code == 403 and error_code == "scope_ceiling_exceeded":
124
+ raise ScopeCeilingException(message)
125
+ if response.status_code == 429 and error_code == "rate_limited":
126
+ raise RateLimitException(message)
127
+
128
+ raise CredError(message, error_code, response.status_code)
129
+
130
+ def get_consent_url(
131
+ self,
132
+ service: str,
133
+ user_id: str,
134
+ app_client_id: str,
135
+ scopes: list[str],
136
+ redirect_uri: str,
137
+ ) -> str:
138
+ params = urlencode(
139
+ {
140
+ "app_client_id": app_client_id,
141
+ "scopes": ",".join(scopes),
142
+ "redirect_uri": redirect_uri,
143
+ },
144
+ )
145
+ return f"{self._base_url}/api/connect/{service}/authorize?{params}"
146
+
147
+
148
+ class Cred(_CredBase):
149
+ """Synchronous client for the Cred credential delegation API."""
150
+
151
+ def __init__(
152
+ self,
153
+ agent_token: str,
154
+ base_url: str = DEFAULT_BASE_URL,
155
+ ) -> None:
156
+ super().__init__(agent_token=agent_token, base_url=base_url)
157
+ self._client = httpx.Client(
158
+ base_url=self._base_url,
159
+ headers=self._auth_headers(),
160
+ timeout=30.0,
161
+ )
162
+
163
+ def delegate(
164
+ self,
165
+ service: str,
166
+ user_id: str,
167
+ app_client_id: Optional[str] = None,
168
+ scopes: Optional[list[str]] = None,
169
+ agent_did: Optional[str] = None,
170
+ ) -> DelegationResult:
171
+ body: dict[str, Any] = {
172
+ "service": service,
173
+ "user_id": user_id,
174
+ }
175
+ if app_client_id:
176
+ body["appClientId"] = app_client_id
177
+ if scopes:
178
+ body["scopes"] = scopes
179
+ if agent_did:
180
+ body["agent_did"] = agent_did
181
+
182
+ data = self._request_json("POST", "/api/v1/delegate", body)
183
+ return self._build_delegation_result(data)
184
+
185
+ def list_connections(
186
+ self,
187
+ user_id: str,
188
+ app_client_id: Optional[str] = None,
189
+ ) -> list[dict[str, Any]]:
190
+ params: dict[str, str] = {"user_id": user_id}
191
+ if app_client_id:
192
+ params["app_client_id"] = app_client_id
193
+
194
+ data = self._request_json("GET", f"/api/v1/connections?{urlencode(params)}")
195
+ return list(data.get("connections", []))
196
+
197
+ def get_user_connections(
198
+ self,
199
+ user_id: str,
200
+ app_client_id: Optional[str] = None,
201
+ ) -> list[Connection]:
202
+ return self._build_connections(
203
+ {"connections": self.list_connections(user_id, app_client_id)},
204
+ )
205
+
206
+ def revoke(
207
+ self,
208
+ service: str,
209
+ user_id: str,
210
+ app_client_id: Optional[str] = None,
211
+ ) -> None:
212
+ params: dict[str, str] = {"user_id": user_id}
213
+ if app_client_id:
214
+ params["app_client_id"] = app_client_id
215
+
216
+ self._request_no_content("DELETE", f"/api/v1/connections/{service}?{urlencode(params)}")
217
+
218
+ def revoke_agent(self, agent_id: str) -> None:
219
+ self._request_no_content("POST", f"/api/v1/agents/{agent_id}/revoke-all", {})
220
+
221
+ def _request_json(self, method: str, path: str, body: Optional[dict[str, Any]] = None) -> dict[str, Any]:
222
+ response = self._client.request(method, path, json=body)
223
+ payload = self._handle_response(response)
224
+ if not isinstance(payload, dict):
225
+ raise CredError("Expected JSON object response", "invalid_response", response.status_code)
226
+ return payload
227
+
228
+ def _request_no_content(self, method: str, path: str, body: Optional[dict[str, Any]] = None) -> None:
229
+ response = self._client.request(method, path, json=body)
230
+ if response.status_code != 204:
231
+ self._handle_response(response)
232
+
233
+ def close(self) -> None:
234
+ self._client.close()
235
+
236
+ def __enter__(self) -> "Cred":
237
+ return self
238
+
239
+ def __exit__(self, *args: Any) -> None:
240
+ self.close()
241
+
242
+
243
+ class AsyncCred(_CredBase):
244
+ """Asynchronous client for the Cred credential delegation API."""
245
+
246
+ def __init__(
247
+ self,
248
+ agent_token: str,
249
+ base_url: str = DEFAULT_BASE_URL,
250
+ ) -> None:
251
+ super().__init__(agent_token=agent_token, base_url=base_url)
252
+ self._client = httpx.AsyncClient(
253
+ base_url=self._base_url,
254
+ headers=self._auth_headers(),
255
+ timeout=30.0,
256
+ )
257
+
258
+ async def delegate(
259
+ self,
260
+ service: str,
261
+ user_id: str,
262
+ app_client_id: Optional[str] = None,
263
+ scopes: Optional[list[str]] = None,
264
+ agent_did: Optional[str] = None,
265
+ ) -> DelegationResult:
266
+ body: dict[str, Any] = {
267
+ "service": service,
268
+ "user_id": user_id,
269
+ }
270
+ if app_client_id:
271
+ body["appClientId"] = app_client_id
272
+ if scopes:
273
+ body["scopes"] = scopes
274
+ if agent_did:
275
+ body["agent_did"] = agent_did
276
+
277
+ data = await self._request_json("POST", "/api/v1/delegate", body)
278
+ return self._build_delegation_result(data)
279
+
280
+ async def list_connections(
281
+ self,
282
+ user_id: str,
283
+ app_client_id: Optional[str] = None,
284
+ ) -> list[dict[str, Any]]:
285
+ params: dict[str, str] = {"user_id": user_id}
286
+ if app_client_id:
287
+ params["app_client_id"] = app_client_id
288
+
289
+ data = await self._request_json("GET", f"/api/v1/connections?{urlencode(params)}")
290
+ return list(data.get("connections", []))
291
+
292
+ async def get_user_connections(
293
+ self,
294
+ user_id: str,
295
+ app_client_id: Optional[str] = None,
296
+ ) -> list[Connection]:
297
+ return self._build_connections(
298
+ {"connections": await self.list_connections(user_id, app_client_id)},
299
+ )
300
+
301
+ async def revoke(
302
+ self,
303
+ service: str,
304
+ user_id: str,
305
+ app_client_id: Optional[str] = None,
306
+ ) -> None:
307
+ params: dict[str, str] = {"user_id": user_id}
308
+ if app_client_id:
309
+ params["app_client_id"] = app_client_id
310
+
311
+ await self._request_no_content("DELETE", f"/api/v1/connections/{service}?{urlencode(params)}")
312
+
313
+ async def revoke_agent(self, agent_id: str) -> None:
314
+ await self._request_no_content("POST", f"/api/v1/agents/{agent_id}/revoke-all", {})
315
+
316
+ async def _request_json(self, method: str, path: str, body: Optional[dict[str, Any]] = None) -> dict[str, Any]:
317
+ response = await self._client.request(method, path, json=body)
318
+ payload = self._handle_response(response)
319
+ if not isinstance(payload, dict):
320
+ raise CredError("Expected JSON object response", "invalid_response", response.status_code)
321
+ return payload
322
+
323
+ async def _request_no_content(self, method: str, path: str, body: Optional[dict[str, Any]] = None) -> None:
324
+ response = await self._client.request(method, path, json=body)
325
+ if response.status_code != 204:
326
+ self._handle_response(response)
327
+
328
+ async def close(self) -> None:
329
+ await self._client.aclose()
330
+
331
+ async def __aenter__(self) -> "AsyncCred":
332
+ return self
333
+
334
+ async def __aexit__(self, *args: Any) -> None:
335
+ await self.close()
cred/exceptions.py ADDED
@@ -0,0 +1,46 @@
1
+ """Cred SDK — Exception classes."""
2
+
3
+
4
+ class CredError(Exception):
5
+ """Base exception for all Cred API errors."""
6
+
7
+ def __init__(self, message: str, code: str, status_code: int) -> None:
8
+ super().__init__(message)
9
+ self.code = code
10
+ self.status_code = status_code
11
+
12
+ def __repr__(self) -> str:
13
+ return f"{self.__class__.__name__}(message={str(self)!r}, code={self.code!r}, status_code={self.status_code})"
14
+
15
+
16
+ class ConsentRequiredError(CredError):
17
+ """Raised when a user has not yet consented to a service.
18
+
19
+ The ``consent_url`` attribute contains the URL to redirect the user to
20
+ complete the OAuth consent flow.
21
+ """
22
+
23
+ def __init__(self, message: str, consent_url: str) -> None:
24
+ super().__init__(message, "consent_required", 403)
25
+ self.consent_url = consent_url
26
+
27
+
28
+ class AgentRevokedException(CredError):
29
+ """Raised when the agent token has been revoked server-side."""
30
+
31
+ def __init__(self, message: str) -> None:
32
+ super().__init__(message, "agent_revoked", 403)
33
+
34
+
35
+ class ScopeCeilingException(CredError):
36
+ """Raised when requested scopes exceed the agent's ceiling."""
37
+
38
+ def __init__(self, message: str) -> None:
39
+ super().__init__(message, "scope_ceiling_exceeded", 403)
40
+
41
+
42
+ class RateLimitException(CredError):
43
+ """Raised when the API rate limits the request."""
44
+
45
+ def __init__(self, message: str) -> None:
46
+ super().__init__(message, "rate_limited", 429)
cred/identity.py ADDED
@@ -0,0 +1,329 @@
1
+ """Agent Identity — DID:key generation and management.
2
+
3
+ Uses Ed25519 key pairs with did:key encoding (RFC draft-multiformats-multibase).
4
+ Uses `cryptography` package for Ed25519 operations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass
11
+ from typing import Optional
12
+
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
14
+ Ed25519PrivateKey,
15
+ Ed25519PublicKey,
16
+ )
17
+ from cryptography.hazmat.primitives import serialization
18
+
19
+ # ── Cred Public Key (pinned at build time) ───────────────────────────────────
20
+
21
+ CRED_PUBLIC_KEY_HEX = "ed50f12caeed3e3054be071c2884fda47752a02b5d98e37dfd79db5fd502699f"
22
+
23
+ # ── Base58btc alphabet (Bitcoin) ─────────────────────────────────────────────
24
+
25
+ _BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
26
+
27
+
28
+ def _encode_base58(data: bytes) -> str:
29
+ """Encode bytes to base58btc string."""
30
+ if len(data) == 0:
31
+ return ""
32
+
33
+ # Count leading zeros
34
+ zeros = 0
35
+ for byte in data:
36
+ if byte != 0:
37
+ break
38
+ zeros += 1
39
+
40
+ # Convert to big integer and repeatedly divide by 58
41
+ size = (len(data) * 138 // 100) + 1
42
+ b58 = [0] * size
43
+ length = 0
44
+
45
+ for byte in data:
46
+ carry = byte
47
+ i = 0
48
+ for j in range(size - 1, -1, -1):
49
+ if carry == 0 and i >= length:
50
+ break
51
+ carry += 256 * b58[j]
52
+ b58[j] = carry % 58
53
+ carry //= 58
54
+ i += 1
55
+ length = i
56
+
57
+ # Skip leading zeros in base58 result
58
+ start = size - length
59
+ while start < size and b58[start] == 0:
60
+ start += 1
61
+
62
+ # Build result string
63
+ result = "1" * zeros
64
+ for i in range(start, size):
65
+ result += _BASE58_ALPHABET[b58[i]]
66
+
67
+ return result
68
+
69
+
70
+ def _decode_base58(s: str) -> bytes:
71
+ """Decode base58btc string to bytes."""
72
+ if len(s) == 0:
73
+ return b""
74
+
75
+ # Build alphabet lookup
76
+ lookup = {char: i for i, char in enumerate(_BASE58_ALPHABET)}
77
+
78
+ # Count leading '1's (zeros)
79
+ zeros = 0
80
+ for char in s:
81
+ if char != "1":
82
+ break
83
+ zeros += 1
84
+
85
+ # Decode to bytes
86
+ size = (len(s) * 733 // 1000) + 1
87
+ result = [0] * size
88
+ length = 0
89
+
90
+ for char in s:
91
+ value = lookup.get(char)
92
+ if value is None:
93
+ raise ValueError(f"Invalid base58 character: {char}")
94
+
95
+ carry = value
96
+ i = 0
97
+ for j in range(size - 1, -1, -1):
98
+ if carry == 0 and i >= length:
99
+ break
100
+ carry += 58 * result[j]
101
+ result[j] = carry % 256
102
+ carry //= 256
103
+ i += 1
104
+ length = i
105
+
106
+ # Skip leading zeros in byte array
107
+ start = size - length
108
+ while start < size and result[start] == 0:
109
+ start += 1
110
+
111
+ # Prepend zeros and return
112
+ return bytes([0] * zeros + result[start:])
113
+
114
+
115
+ # ── Ed25519 multicodec prefix ────────────────────────────────────────────────
116
+
117
+ # did:key multicodec prefix for Ed25519 public keys: 0xed01
118
+ # Encoded as varint: [0xed, 0x01]
119
+ _ED25519_MULTICODEC_PREFIX = bytes([0xED, 0x01])
120
+
121
+
122
+ # ── Types ────────────────────────────────────────────────────────────────────
123
+
124
+
125
+ @dataclass
126
+ class ExportedIdentity:
127
+ """Exported identity for persistence."""
128
+
129
+ did: str
130
+ private_key_hex: str
131
+
132
+
133
+ class AgentIdentity:
134
+ """Agent identity with Ed25519 key pair and DID.
135
+
136
+ Attributes:
137
+ did: DID in did:key format: did:key:z6Mk<base58-encoded-public-key>
138
+ public_key: Raw 32-byte Ed25519 public key
139
+ private_key: Raw 32-byte Ed25519 private key
140
+ """
141
+
142
+ def __init__(
143
+ self,
144
+ did: str,
145
+ public_key: bytes,
146
+ private_key: bytes,
147
+ ) -> None:
148
+ self._did = did
149
+ self._public_key = bytes(public_key)
150
+ self._private_key = bytes(private_key)
151
+
152
+ @property
153
+ def did(self) -> str:
154
+ return self._did
155
+
156
+ @property
157
+ def public_key(self) -> bytes:
158
+ return bytes(self._public_key)
159
+
160
+ @property
161
+ def private_key(self) -> bytes:
162
+ return bytes(self._private_key)
163
+
164
+ def export(self) -> dict[str, str]:
165
+ """Export identity for persistence.
166
+
167
+ Returns:
168
+ Dict with 'did' and 'private_key_hex' keys.
169
+ """
170
+ return {
171
+ "did": self._did,
172
+ "private_key_hex": self._private_key.hex(),
173
+ }
174
+
175
+ @classmethod
176
+ def from_export(cls, data: dict[str, str]) -> "AgentIdentity":
177
+ """Import a persisted agent identity.
178
+
179
+ Args:
180
+ data: Dict with 'did' and 'private_key_hex' keys.
181
+
182
+ Returns:
183
+ AgentIdentity with full key pair.
184
+
185
+ Raises:
186
+ ValueError: If DID format is invalid or private key is wrong size.
187
+ """
188
+ did = data["did"]
189
+ private_key_hex = data["private_key_hex"]
190
+
191
+ # Validate DID format
192
+ if not did.startswith("did:key:z"):
193
+ raise ValueError("Invalid DID format: must start with did:key:z")
194
+
195
+ # Decode private key from hex
196
+ private_key_bytes = bytes.fromhex(private_key_hex)
197
+ if len(private_key_bytes) != 32:
198
+ raise ValueError("Invalid private key: must be 32 bytes")
199
+
200
+ # Derive public key from private key using cryptography
201
+ private_key_obj = Ed25519PrivateKey.from_private_bytes(private_key_bytes)
202
+ public_key_obj = private_key_obj.public_key()
203
+ public_key_bytes = public_key_obj.public_bytes(
204
+ encoding=serialization.Encoding.Raw,
205
+ format=serialization.PublicFormat.Raw,
206
+ )
207
+
208
+ # Verify DID matches derived public key
209
+ derived_did = _public_key_to_did(public_key_bytes)
210
+ if derived_did != did:
211
+ raise ValueError("DID does not match derived public key")
212
+
213
+ return cls(did, public_key_bytes, private_key_bytes)
214
+
215
+
216
+ def _public_key_to_did(public_key: bytes) -> str:
217
+ """Convert raw Ed25519 public key to did:key format."""
218
+ # Prepend multicodec prefix and encode as base58btc
219
+ prefixed = _ED25519_MULTICODEC_PREFIX + public_key
220
+ # 'z' is the multibase prefix for base58btc
221
+ return f"did:key:z{_encode_base58(prefixed)}"
222
+
223
+
224
+ def generate_agent_identity() -> AgentIdentity:
225
+ """Generate a new agent identity with Ed25519 key pair.
226
+
227
+ Returns:
228
+ AgentIdentity with DID, public key, private key, and export function.
229
+ """
230
+ # Generate new Ed25519 key pair
231
+ private_key_obj = Ed25519PrivateKey.generate()
232
+ public_key_obj = private_key_obj.public_key()
233
+
234
+ # Extract raw key bytes
235
+ private_key_bytes = private_key_obj.private_bytes(
236
+ encoding=serialization.Encoding.Raw,
237
+ format=serialization.PrivateFormat.Raw,
238
+ encryption_algorithm=serialization.NoEncryption(),
239
+ )
240
+ public_key_bytes = public_key_obj.public_bytes(
241
+ encoding=serialization.Encoding.Raw,
242
+ format=serialization.PublicFormat.Raw,
243
+ )
244
+
245
+ # Build did:key from public key
246
+ did = _public_key_to_did(public_key_bytes)
247
+
248
+ return AgentIdentity(did, public_key_bytes, private_key_bytes)
249
+
250
+
251
+ # ── Receipt Verification ─────────────────────────────────────────────────────
252
+
253
+
254
+ def _base64url_decode(s: str) -> bytes:
255
+ """Decode base64url string to bytes."""
256
+ import base64
257
+
258
+ # Replace URL-safe characters and add padding
259
+ s = s.replace("-", "+").replace("_", "/")
260
+ # Add padding
261
+ padding = 4 - (len(s) % 4)
262
+ if padding != 4:
263
+ s += "=" * padding
264
+ return base64.b64decode(s)
265
+
266
+
267
+ def verify_delegation_receipt(
268
+ receipt: Optional[str],
269
+ expected_did: str,
270
+ cred_public_key: Optional[str] = None,
271
+ ) -> bool:
272
+ """Verify a delegation receipt from the Cred API.
273
+
274
+ Receipts are JWS compact serialization (header.payload.signature) signed
275
+ by Cred's Ed25519 key. Returns True if signature is valid and DID matches.
276
+
277
+ Args:
278
+ receipt: JWS compact serialization string (or None).
279
+ expected_did: Expected agent DID (sub claim in receipt payload).
280
+ cred_public_key: Cred's public key in hex format (defaults to CRED_PUBLIC_KEY_HEX).
281
+
282
+ Returns:
283
+ True if receipt is valid and matches expected DID, False otherwise.
284
+
285
+ Raises:
286
+ ValueError: If CRED_PUBLIC_KEY_HEX is still placeholder.
287
+ """
288
+ # Return False for missing receipts (don't throw)
289
+ if not receipt:
290
+ return False
291
+
292
+ cred_public_key_hex = cred_public_key or CRED_PUBLIC_KEY_HEX
293
+
294
+ # Placeholder check — can't verify without real key
295
+ if cred_public_key_hex == "PLACEHOLDER_REPLACE_BEFORE_LAUNCH":
296
+ raise ValueError("CRED_PUBLIC_KEY_HEX is placeholder — cannot verify receipts")
297
+
298
+ try:
299
+ # Parse JWS compact serialization: header.payload.signature
300
+ parts = receipt.split(".")
301
+ if len(parts) != 3:
302
+ return False
303
+
304
+ header_b64, payload_b64, signature_b64 = parts
305
+
306
+ # Decode and validate header
307
+ header = json.loads(_base64url_decode(header_b64).decode("utf-8"))
308
+ if header.get("alg") != "EdDSA" or header.get("typ") != "JWT":
309
+ return False
310
+
311
+ # Decode payload and verify DID matches
312
+ payload = json.loads(_base64url_decode(payload_b64).decode("utf-8"))
313
+ if payload.get("sub") != expected_did:
314
+ return False
315
+
316
+ # Verify signature
317
+ signature_input = f"{header_b64}.{payload_b64}".encode("utf-8")
318
+ signature = _base64url_decode(signature_b64)
319
+
320
+ # Build Ed25519 public key object from raw bytes
321
+ cred_public_key_bytes = bytes.fromhex(cred_public_key_hex)
322
+ if len(cred_public_key_bytes) != 32:
323
+ return False
324
+
325
+ public_key_obj = Ed25519PublicKey.from_public_bytes(cred_public_key_bytes)
326
+ public_key_obj.verify(signature, signature_input)
327
+ return True
328
+ except Exception:
329
+ return False
cred/models.py ADDED
@@ -0,0 +1,31 @@
1
+ """Cred SDK — Response model dataclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+
10
+ @dataclass
11
+ class DelegationResult:
12
+ """Result of a successful delegation request."""
13
+
14
+ access_token: str
15
+ token_type: str
16
+ expires_in: int
17
+ expires_at: datetime
18
+ service: str
19
+ scopes: list[str]
20
+ delegation_id: str
21
+ receipt: Optional[str] = None
22
+
23
+
24
+ @dataclass
25
+ class Connection:
26
+ """A user's active service connection."""
27
+
28
+ slug: str
29
+ scopes_granted: list[str]
30
+ app_client_id: Optional[str] = None
31
+ consented_at: Optional[str] = None
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: cred-auth
3
+ Version: 1.0.0
4
+ Summary: Cred — credential delegation SDK for AI agents
5
+ Project-URL: Homepage, https://cred.ninja
6
+ Project-URL: Repository, https://github.com/cred-ninja/sdk
7
+ Project-URL: Documentation, https://docs.cred.ninja
8
+ Author-email: Cred <hello@cred.ninja>
9
+ License: Apache-2.0
10
+ Keywords: ai-agents,cred,credentials,delegation,oauth
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: cryptography>=42.0
22
+ Requires-Dist: httpx>=0.24
23
+ Provides-Extra: dev
24
+ Requires-Dist: hatchling; extra == 'dev'
25
+ Requires-Dist: mypy>=1.10; extra == 'dev'
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Requires-Dist: respx>=0.20; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # cred-auth
31
+
32
+ Python SDK for Cred. OAuth2 credential delegation for AI agents. Tokens are brokered, never exposed.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install cred-auth
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ```python
43
+ import os
44
+ from cred import Cred, ConsentRequiredError
45
+
46
+ cred = Cred(agent_token=os.environ["CRED_AGENT_TOKEN"])
47
+
48
+ try:
49
+ result = cred.delegate(
50
+ service="google",
51
+ user_id="user_123",
52
+ app_client_id="my_app_client_id",
53
+ scopes=["calendar.readonly"],
54
+ )
55
+ print(result.access_token)
56
+ except ConsentRequiredError as e:
57
+ # Redirect user to e.consent_url to complete OAuth consent
58
+ print(f"Redirect user to: {e.consent_url}")
59
+ ```
60
+
61
+ ## API
62
+
63
+ ### `Cred(agent_token, base_url=...)`
64
+
65
+ ### `delegate(service, user_id, app_client_id, scopes=None) → DelegationResult`
66
+
67
+ Get a delegated access token for a service on behalf of a user.
68
+
69
+ Raises `ConsentRequiredError` (with `.consent_url`) if the user hasn't connected the service.
70
+
71
+ ### `get_user_connections(user_id, app_client_id=None) → list[Connection]`
72
+
73
+ List all active service connections for a user.
74
+
75
+ ### `get_consent_url(service, user_id, app_client_id, scopes, redirect_uri) → str`
76
+
77
+ Build a consent URL. Pure URL construction, no HTTP call.
78
+
79
+ ### `revoke(service, user_id, app_client_id=None) → None`
80
+
81
+ Revoke a user's connection to a service.
82
+
83
+ ## Context Manager
84
+
85
+ ```python
86
+ with Cred(agent_token=token) as cred:
87
+ result = cred.delegate(service="github", user_id="u1", app_client_id="app1")
88
+ ```
89
+
90
+ ## Self-Hosted Server Mode
91
+
92
+ Point `base_url` at your own `@credninja/server` deployment when you want a separate credential broker instead of local-only usage.
@@ -0,0 +1,8 @@
1
+ cred/__init__.py,sha256=detfphK_u0GBcnE2qyxcZU9Au1qO3nej76PYEoUB4zs,746
2
+ cred/client.py,sha256=6x4vqGXUhy-B8A4Q4iV-hWRYyevq-ho0V1SRjvkwWnU,11293
3
+ cred/exceptions.py,sha256=_U64mjaMAY6q5PxyO5-ud0xOJ11N6IqddoS-suXc3vY,1460
4
+ cred/identity.py,sha256=ka9ZV8fNrQJtO1vJ6tev6Ltg7KFRU74OKLOCFFrZmg0,10240
5
+ cred/models.py,sha256=i2O6SQRkpq5EhXvzW5rA8p8-Q4UF2cUjeu5yCsngPwc,651
6
+ cred_auth-1.0.0.dist-info/METADATA,sha256=3w3yui_PHY081tC7TMSyhdbiZWHTVbHrqS9wOLFwPZY,2780
7
+ cred_auth-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ cred_auth-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any