cred-auth 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ node_modules/
2
+ dist/
3
+ build/
4
+
5
+ # Sprint artifacts
6
+ .factory/
7
+ .sprint-*
8
+ .sprint-pid
9
+ .rewire-done
10
+ .content-rewrite-done
11
+ .hubspot-done
12
+ .integrations-done
13
+ .done
14
+ .validation-report.md
15
+ .sprint-logs/
16
+ .sprint-prompts/
17
+ SESSION_PACKAGE.md
18
+ run-sprint.sh
19
+ validate.sh
20
+
21
+ # Internal planning docs (not for public repo)
22
+ CLAUDE.md
23
+ PLAN.md
24
+ HANDOFF.md
25
+ VALIDATION.md
26
+ MARKETING-VALIDATION.md
27
+ FINAL-SUBMISSION-VALIDATION.md
28
+ .next/
29
+ out/
30
+ coverage/
31
+ *.tsbuildinfo
32
+ .turbo/
33
+ .DS_Store
34
+ *.pem
35
+ .env
36
+ .env.local
37
+ .env*.local
38
+ !.env.example
39
+
40
+ # Python
41
+ __pycache__/
42
+ *.py[cod]
43
+ *.pyo
44
+ *.egg-info/
45
+ .pytest_cache/
46
+ .venv/
47
+ venv/
48
+ dist/
49
+ *.dist-info/
50
+ packages/sdk-python/.venv/
@@ -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,63 @@
1
+ # cred-auth
2
+
3
+ Python SDK for Cred. OAuth2 credential delegation for AI agents. Tokens are brokered, never exposed.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install cred-auth
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ import os
15
+ from cred import Cred, ConsentRequiredError
16
+
17
+ cred = Cred(agent_token=os.environ["CRED_AGENT_TOKEN"])
18
+
19
+ try:
20
+ result = cred.delegate(
21
+ service="google",
22
+ user_id="user_123",
23
+ app_client_id="my_app_client_id",
24
+ scopes=["calendar.readonly"],
25
+ )
26
+ print(result.access_token)
27
+ except ConsentRequiredError as e:
28
+ # Redirect user to e.consent_url to complete OAuth consent
29
+ print(f"Redirect user to: {e.consent_url}")
30
+ ```
31
+
32
+ ## API
33
+
34
+ ### `Cred(agent_token, base_url=...)`
35
+
36
+ ### `delegate(service, user_id, app_client_id, scopes=None) → DelegationResult`
37
+
38
+ Get a delegated access token for a service on behalf of a user.
39
+
40
+ Raises `ConsentRequiredError` (with `.consent_url`) if the user hasn't connected the service.
41
+
42
+ ### `get_user_connections(user_id, app_client_id=None) → list[Connection]`
43
+
44
+ List all active service connections for a user.
45
+
46
+ ### `get_consent_url(service, user_id, app_client_id, scopes, redirect_uri) → str`
47
+
48
+ Build a consent URL. Pure URL construction, no HTTP call.
49
+
50
+ ### `revoke(service, user_id, app_client_id=None) → None`
51
+
52
+ Revoke a user's connection to a service.
53
+
54
+ ## Context Manager
55
+
56
+ ```python
57
+ with Cred(agent_token=token) as cred:
58
+ result = cred.delegate(service="github", user_id="u1", app_client_id="app1")
59
+ ```
60
+
61
+ ## Self-Hosted Server Mode
62
+
63
+ Point `base_url` at your own `@credninja/server` deployment when you want a separate credential broker instead of local-only usage.
@@ -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
+ ]
@@ -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()
@@ -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)