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.
- cred_auth-1.0.0/.gitignore +50 -0
- cred_auth-1.0.0/PKG-INFO +92 -0
- cred_auth-1.0.0/README.md +63 -0
- cred_auth-1.0.0/cred/__init__.py +33 -0
- cred_auth-1.0.0/cred/client.py +335 -0
- cred_auth-1.0.0/cred/exceptions.py +46 -0
- cred_auth-1.0.0/cred/identity.py +329 -0
- cred_auth-1.0.0/cred/models.py +31 -0
- cred_auth-1.0.0/pyproject.toml +42 -0
- cred_auth-1.0.0/tests/__init__.py +0 -0
- cred_auth-1.0.0/tests/test_client.py +384 -0
- cred_auth-1.0.0/tests/test_identity.py +81 -0
|
@@ -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/
|
cred_auth-1.0.0/PKG-INFO
ADDED
|
@@ -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)
|