oneid 0.1.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.
- oneid-0.1.0/PKG-INFO +85 -0
- oneid-0.1.0/README.md +53 -0
- oneid-0.1.0/oneid/__init__.py +154 -0
- oneid-0.1.0/oneid/_version.py +3 -0
- oneid-0.1.0/oneid/auth.py +331 -0
- oneid-0.1.0/oneid/client.py +305 -0
- oneid-0.1.0/oneid/credentials.py +230 -0
- oneid-0.1.0/oneid/enroll.py +396 -0
- oneid-0.1.0/oneid/exceptions.py +224 -0
- oneid-0.1.0/oneid/helper.py +787 -0
- oneid-0.1.0/oneid/identity.py +142 -0
- oneid-0.1.0/oneid/keys.py +156 -0
- oneid-0.1.0/oneid.egg-info/PKG-INFO +85 -0
- oneid-0.1.0/oneid.egg-info/SOURCES.txt +25 -0
- oneid-0.1.0/oneid.egg-info/dependency_links.txt +1 -0
- oneid-0.1.0/oneid.egg-info/requires.txt +8 -0
- oneid-0.1.0/oneid.egg-info/top_level.txt +1 -0
- oneid-0.1.0/pyproject.toml +69 -0
- oneid-0.1.0/setup.cfg +4 -0
- oneid-0.1.0/tests/test_credentials.py +187 -0
- oneid-0.1.0/tests/test_enroll_declared.py +210 -0
- oneid-0.1.0/tests/test_exceptions.py +211 -0
- oneid-0.1.0/tests/test_identity.py +156 -0
- oneid-0.1.0/tests/test_integration_declared_enrollment_against_live_server.py +210 -0
- oneid-0.1.0/tests/test_keys.py +166 -0
- oneid-0.1.0/tests/test_sovereign_enrollment_round_trip.py +268 -0
- oneid-0.1.0/tests/test_token.py +175 -0
oneid-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oneid
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hardware-anchored identity SDK for AI agents -- 1id.com
|
|
5
|
+
Author-email: Christopher Drake <chris@1id.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://1id.com
|
|
8
|
+
Project-URL: Documentation, https://1id.com/enroll.md
|
|
9
|
+
Project-URL: Repository, https://github.com/AuraFriday/oneid-sdk
|
|
10
|
+
Project-URL: Issues, https://github.com/AuraFriday/oneid-sdk/issues
|
|
11
|
+
Keywords: identity,ai,agent,tpm,hardware,oidc,oauth2,sybil
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Security :: Cryptography
|
|
22
|
+
Classifier: Topic :: System :: Hardware
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: httpx>=0.24.0
|
|
26
|
+
Requires-Dist: cryptography>=41.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
32
|
+
|
|
33
|
+
# oneid-sdk
|
|
34
|
+
|
|
35
|
+
Python SDK for [1id.com](https://1id.com) -- hardware-anchored identity for AI agents.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import oneid
|
|
41
|
+
|
|
42
|
+
# Enroll at declared tier (no HSM needed, always works)
|
|
43
|
+
identity = oneid.enroll(request_tier="declared")
|
|
44
|
+
print(f"Enrolled: {identity.handle}")
|
|
45
|
+
|
|
46
|
+
# Get an OAuth2 token for API access
|
|
47
|
+
token = oneid.get_token()
|
|
48
|
+
headers = {"Authorization": token.authorization_header_value}
|
|
49
|
+
|
|
50
|
+
# Check identity
|
|
51
|
+
me = oneid.whoami()
|
|
52
|
+
print(f"I am {me.handle}, trust tier: {me.trust_tier.value}")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Trust tiers
|
|
56
|
+
|
|
57
|
+
| Tier | Hardware | Sybil resistance |
|
|
58
|
+
|------|----------|-----------------|
|
|
59
|
+
| `sovereign` | TPM (discrete/firmware) | Highest -- manufacturer-attested |
|
|
60
|
+
| `sovereign-portable` | YubiKey/Nitrokey | High -- manufacturer-attested |
|
|
61
|
+
| `declared` | None (software keys) | Lowest -- self-asserted |
|
|
62
|
+
|
|
63
|
+
`request_tier` is a **requirement**, not a preference. You get exactly what you ask for, or an exception. No silent fallbacks.
|
|
64
|
+
|
|
65
|
+
## Key algorithms
|
|
66
|
+
|
|
67
|
+
Like SSH, agents can choose their preferred key algorithm for declared-tier enrollment:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
identity = oneid.enroll(request_tier="declared", key_algorithm="ed25519") # default, strongest
|
|
71
|
+
identity = oneid.enroll(request_tier="declared", key_algorithm="ecdsa-p384") # NIST P-384
|
|
72
|
+
identity = oneid.enroll(request_tier="declared", key_algorithm="rsa-4096") # legacy compat
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Installation
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install oneid-sdk
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Requires Python 3.10+.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
Apache-2.0
|
oneid-0.1.0/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# oneid-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for [1id.com](https://1id.com) -- hardware-anchored identity for AI agents.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import oneid
|
|
9
|
+
|
|
10
|
+
# Enroll at declared tier (no HSM needed, always works)
|
|
11
|
+
identity = oneid.enroll(request_tier="declared")
|
|
12
|
+
print(f"Enrolled: {identity.handle}")
|
|
13
|
+
|
|
14
|
+
# Get an OAuth2 token for API access
|
|
15
|
+
token = oneid.get_token()
|
|
16
|
+
headers = {"Authorization": token.authorization_header_value}
|
|
17
|
+
|
|
18
|
+
# Check identity
|
|
19
|
+
me = oneid.whoami()
|
|
20
|
+
print(f"I am {me.handle}, trust tier: {me.trust_tier.value}")
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Trust tiers
|
|
24
|
+
|
|
25
|
+
| Tier | Hardware | Sybil resistance |
|
|
26
|
+
|------|----------|-----------------|
|
|
27
|
+
| `sovereign` | TPM (discrete/firmware) | Highest -- manufacturer-attested |
|
|
28
|
+
| `sovereign-portable` | YubiKey/Nitrokey | High -- manufacturer-attested |
|
|
29
|
+
| `declared` | None (software keys) | Lowest -- self-asserted |
|
|
30
|
+
|
|
31
|
+
`request_tier` is a **requirement**, not a preference. You get exactly what you ask for, or an exception. No silent fallbacks.
|
|
32
|
+
|
|
33
|
+
## Key algorithms
|
|
34
|
+
|
|
35
|
+
Like SSH, agents can choose their preferred key algorithm for declared-tier enrollment:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
identity = oneid.enroll(request_tier="declared", key_algorithm="ed25519") # default, strongest
|
|
39
|
+
identity = oneid.enroll(request_tier="declared", key_algorithm="ecdsa-p384") # NIST P-384
|
|
40
|
+
identity = oneid.enroll(request_tier="declared", key_algorithm="rsa-4096") # legacy compat
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install oneid-sdk
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Requires Python 3.10+.
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
Apache-2.0
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
1id.com SDK -- Hardware-anchored identity for AI agents.
|
|
3
|
+
|
|
4
|
+
Quick start:
|
|
5
|
+
|
|
6
|
+
import oneid
|
|
7
|
+
|
|
8
|
+
# Enroll at declared tier (no HSM, always works)
|
|
9
|
+
identity = oneid.enroll(request_tier="declared")
|
|
10
|
+
print(f"Enrolled as {identity.handle}")
|
|
11
|
+
|
|
12
|
+
# Get an OAuth2 token for authentication
|
|
13
|
+
token = oneid.get_token()
|
|
14
|
+
print(f"Bearer {token.access_token}")
|
|
15
|
+
|
|
16
|
+
# Check current identity
|
|
17
|
+
identity = oneid.whoami()
|
|
18
|
+
|
|
19
|
+
Trust tiers (request_tier parameter):
|
|
20
|
+
'sovereign' -- TPM hardware, manufacturer-attested
|
|
21
|
+
'sovereign-portable' -- YubiKey/Nitrokey, manufacturer-attested
|
|
22
|
+
'declared' -- Software keys, no hardware proof
|
|
23
|
+
|
|
24
|
+
CRITICAL: request_tier is a REQUIREMENT, not a preference.
|
|
25
|
+
You get exactly what you ask for, or an exception. No fallbacks.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .auth import clear_cached_token, get_token
|
|
29
|
+
from .credentials import credentials_exist, load_credentials
|
|
30
|
+
from .enroll import enroll
|
|
31
|
+
from .exceptions import (
|
|
32
|
+
AlreadyEnrolledError,
|
|
33
|
+
AuthenticationError,
|
|
34
|
+
BinaryNotFoundError,
|
|
35
|
+
EnrollmentError,
|
|
36
|
+
HandleInvalidError,
|
|
37
|
+
HandleRetiredError,
|
|
38
|
+
HandleTakenError,
|
|
39
|
+
HSMAccessError,
|
|
40
|
+
NetworkError,
|
|
41
|
+
NoHSMError,
|
|
42
|
+
NotEnrolledError,
|
|
43
|
+
OneIDError,
|
|
44
|
+
UACDeniedError,
|
|
45
|
+
)
|
|
46
|
+
from .identity import (
|
|
47
|
+
DEFAULT_KEY_ALGORITHM,
|
|
48
|
+
HSMType,
|
|
49
|
+
Identity,
|
|
50
|
+
KeyAlgorithm,
|
|
51
|
+
Token,
|
|
52
|
+
TrustTier,
|
|
53
|
+
)
|
|
54
|
+
from .keys import sign_challenge_with_private_key
|
|
55
|
+
from ._version import __version__
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def whoami() -> Identity:
|
|
59
|
+
"""Check the current enrolled identity.
|
|
60
|
+
|
|
61
|
+
Reads the local credentials file and returns the identity information
|
|
62
|
+
stored during enrollment. Does NOT make a network request.
|
|
63
|
+
|
|
64
|
+
For a network-verified identity check, use the server API directly.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Identity: The enrolled identity.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
NotEnrolledError: If no credentials exist (call enroll() first).
|
|
71
|
+
"""
|
|
72
|
+
from datetime import datetime, timezone
|
|
73
|
+
|
|
74
|
+
creds = load_credentials()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
trust_tier = TrustTier(creds.trust_tier)
|
|
78
|
+
except ValueError:
|
|
79
|
+
trust_tier = TrustTier.DECLARED
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
key_algorithm = KeyAlgorithm(creds.key_algorithm)
|
|
83
|
+
except ValueError:
|
|
84
|
+
key_algorithm = DEFAULT_KEY_ALGORITHM
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
enrolled_at = datetime.fromisoformat(creds.enrolled_at.replace("Z", "+00:00")) if creds.enrolled_at else datetime.now(timezone.utc)
|
|
88
|
+
except (ValueError, AttributeError):
|
|
89
|
+
enrolled_at = datetime.now(timezone.utc)
|
|
90
|
+
|
|
91
|
+
internal_id = creds.client_id
|
|
92
|
+
handle = f"@{internal_id}" if not internal_id.startswith("@") else internal_id
|
|
93
|
+
|
|
94
|
+
# Determine HSM type from credentials
|
|
95
|
+
hsm_type: HSMType | None = None
|
|
96
|
+
if creds.private_key_pem is not None:
|
|
97
|
+
hsm_type = HSMType.SOFTWARE
|
|
98
|
+
elif creds.hsm_key_reference is not None:
|
|
99
|
+
hsm_type = HSMType.TPM # Could also be YubiKey, but we'd need more info
|
|
100
|
+
|
|
101
|
+
return Identity(
|
|
102
|
+
internal_id=internal_id,
|
|
103
|
+
handle=handle,
|
|
104
|
+
trust_tier=trust_tier,
|
|
105
|
+
hsm_type=hsm_type,
|
|
106
|
+
hsm_manufacturer=None,
|
|
107
|
+
enrolled_at=enrolled_at,
|
|
108
|
+
device_count=1 if creds.hsm_key_reference else 0,
|
|
109
|
+
key_algorithm=key_algorithm,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def refresh() -> None:
|
|
114
|
+
"""Force-refresh the cached OAuth2 token.
|
|
115
|
+
|
|
116
|
+
Discards the in-memory cached token and fetches a new one
|
|
117
|
+
on the next get_token() call.
|
|
118
|
+
"""
|
|
119
|
+
clear_cached_token()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# -- Public API --
|
|
123
|
+
__all__ = [
|
|
124
|
+
# Core functions
|
|
125
|
+
"enroll",
|
|
126
|
+
"get_token",
|
|
127
|
+
"whoami",
|
|
128
|
+
"refresh",
|
|
129
|
+
"credentials_exist",
|
|
130
|
+
"sign_challenge_with_private_key",
|
|
131
|
+
# Data types
|
|
132
|
+
"Identity",
|
|
133
|
+
"Token",
|
|
134
|
+
"TrustTier",
|
|
135
|
+
"KeyAlgorithm",
|
|
136
|
+
"HSMType",
|
|
137
|
+
"DEFAULT_KEY_ALGORITHM",
|
|
138
|
+
# Exceptions (all importable from oneid directly)
|
|
139
|
+
"OneIDError",
|
|
140
|
+
"EnrollmentError",
|
|
141
|
+
"NoHSMError",
|
|
142
|
+
"UACDeniedError",
|
|
143
|
+
"HSMAccessError",
|
|
144
|
+
"AlreadyEnrolledError",
|
|
145
|
+
"HandleTakenError",
|
|
146
|
+
"HandleInvalidError",
|
|
147
|
+
"HandleRetiredError",
|
|
148
|
+
"AuthenticationError",
|
|
149
|
+
"NetworkError",
|
|
150
|
+
"NotEnrolledError",
|
|
151
|
+
"BinaryNotFoundError",
|
|
152
|
+
# Version
|
|
153
|
+
"__version__",
|
|
154
|
+
]
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth2 token management for the 1id.com SDK.
|
|
3
|
+
|
|
4
|
+
After enrollment, agents authenticate using standard OAuth2
|
|
5
|
+
client_credentials grant. No TPM operations needed for daily use.
|
|
6
|
+
|
|
7
|
+
This module handles:
|
|
8
|
+
- Token acquisition (client_credentials grant)
|
|
9
|
+
- Token caching (in-memory, with expiry awareness)
|
|
10
|
+
- Token refresh
|
|
11
|
+
- Authorization header formatting
|
|
12
|
+
|
|
13
|
+
The token endpoint is Keycloak:
|
|
14
|
+
POST https://1id.com/realms/agents/protocol/openid-connect/token
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from .credentials import StoredCredentials, load_credentials
|
|
26
|
+
from .exceptions import AuthenticationError, NetworkError, NotEnrolledError
|
|
27
|
+
from .identity import Token
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("oneid.auth")
|
|
30
|
+
|
|
31
|
+
# -- Configuration --
|
|
32
|
+
TOKEN_REFRESH_MARGIN_SECONDS = 60 # Refresh tokens this many seconds before expiry
|
|
33
|
+
TOKEN_REQUEST_TIMEOUT_SECONDS = 15.0
|
|
34
|
+
|
|
35
|
+
# -- Module-level token cache --
|
|
36
|
+
_cached_token: Token | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_token(
|
|
40
|
+
force_refresh: bool = False,
|
|
41
|
+
credentials: StoredCredentials | None = None,
|
|
42
|
+
) -> Token:
|
|
43
|
+
"""Get a valid OAuth2 access token, refreshing if needed.
|
|
44
|
+
|
|
45
|
+
This is the primary authentication method for daily use. It uses
|
|
46
|
+
the OAuth2 client_credentials grant with the credentials stored
|
|
47
|
+
during enrollment.
|
|
48
|
+
|
|
49
|
+
Tokens are cached in memory and automatically refreshed when they
|
|
50
|
+
are within TOKEN_REFRESH_MARGIN_SECONDS of expiry.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
force_refresh: If True, always fetch a new token even if the
|
|
54
|
+
cached one is still valid.
|
|
55
|
+
credentials: Optional pre-loaded credentials. If None, loads
|
|
56
|
+
from the credentials file.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A valid Token object.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
NotEnrolledError: If no credentials file exists.
|
|
63
|
+
AuthenticationError: If the token request fails.
|
|
64
|
+
NetworkError: If the token endpoint cannot be reached.
|
|
65
|
+
"""
|
|
66
|
+
global _cached_token
|
|
67
|
+
|
|
68
|
+
# Check if cached token is still valid (with margin)
|
|
69
|
+
if not force_refresh and _cached_token is not None:
|
|
70
|
+
margin = timedelta(seconds=TOKEN_REFRESH_MARGIN_SECONDS)
|
|
71
|
+
if datetime.now(timezone.utc) + margin < _cached_token.expires_at:
|
|
72
|
+
return _cached_token
|
|
73
|
+
|
|
74
|
+
# Load credentials
|
|
75
|
+
if credentials is None:
|
|
76
|
+
credentials = load_credentials()
|
|
77
|
+
|
|
78
|
+
# Request a new token
|
|
79
|
+
token = _request_token_from_keycloak(credentials)
|
|
80
|
+
_cached_token = token
|
|
81
|
+
|
|
82
|
+
return token
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _request_token_from_keycloak(credentials: StoredCredentials) -> Token:
|
|
86
|
+
"""Request a new access token from Keycloak using client_credentials grant.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
credentials: The stored enrollment credentials.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A new Token object.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
AuthenticationError: If the token request fails (401, 403, etc.).
|
|
96
|
+
NetworkError: If the token endpoint cannot be reached.
|
|
97
|
+
"""
|
|
98
|
+
token_endpoint = credentials.token_endpoint
|
|
99
|
+
|
|
100
|
+
request_body = {
|
|
101
|
+
"grant_type": "client_credentials",
|
|
102
|
+
"client_id": credentials.client_id,
|
|
103
|
+
"client_secret": credentials.client_secret,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
with httpx.Client(timeout=TOKEN_REQUEST_TIMEOUT_SECONDS) as http_client:
|
|
108
|
+
response = http_client.post(
|
|
109
|
+
token_endpoint,
|
|
110
|
+
data=request_body,
|
|
111
|
+
headers={
|
|
112
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
113
|
+
"User-Agent": "oneid-sdk-python/0.1.0",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
except httpx.ConnectError as connection_error:
|
|
117
|
+
raise NetworkError(
|
|
118
|
+
f"Could not connect to token endpoint {token_endpoint}: {connection_error}"
|
|
119
|
+
) from connection_error
|
|
120
|
+
except httpx.TimeoutException as timeout_error:
|
|
121
|
+
raise NetworkError(
|
|
122
|
+
f"Token request to {token_endpoint} timed out: {timeout_error}"
|
|
123
|
+
) from timeout_error
|
|
124
|
+
except httpx.HTTPError as http_error:
|
|
125
|
+
raise NetworkError(
|
|
126
|
+
f"HTTP error requesting token from {token_endpoint}: {http_error}"
|
|
127
|
+
) from http_error
|
|
128
|
+
|
|
129
|
+
if response.status_code != 200:
|
|
130
|
+
# Keycloak returns error details in JSON
|
|
131
|
+
try:
|
|
132
|
+
error_body = response.json()
|
|
133
|
+
error_description = error_body.get("error_description", error_body.get("error", "Unknown error"))
|
|
134
|
+
except Exception:
|
|
135
|
+
error_description = f"HTTP {response.status_code}: {response.text[:200]}"
|
|
136
|
+
|
|
137
|
+
raise AuthenticationError(
|
|
138
|
+
f"Token request failed: {error_description}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
token_response = response.json()
|
|
143
|
+
except Exception as json_error:
|
|
144
|
+
raise AuthenticationError(
|
|
145
|
+
f"Invalid JSON in token response: {json_error}"
|
|
146
|
+
) from json_error
|
|
147
|
+
|
|
148
|
+
# Parse the standard OAuth2 token response
|
|
149
|
+
access_token = token_response.get("access_token")
|
|
150
|
+
if not access_token:
|
|
151
|
+
raise AuthenticationError("Token response missing 'access_token' field")
|
|
152
|
+
|
|
153
|
+
expires_in_seconds = token_response.get("expires_in", 3600)
|
|
154
|
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)
|
|
155
|
+
|
|
156
|
+
return Token(
|
|
157
|
+
access_token=access_token,
|
|
158
|
+
token_type=token_response.get("token_type", "Bearer"),
|
|
159
|
+
expires_at=expires_at,
|
|
160
|
+
refresh_token=token_response.get("refresh_token"),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def clear_cached_token() -> None:
|
|
165
|
+
"""Clear the in-memory cached token.
|
|
166
|
+
|
|
167
|
+
Useful for testing or when credentials have changed.
|
|
168
|
+
"""
|
|
169
|
+
global _cached_token
|
|
170
|
+
_cached_token = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# TPM-backed passwordless authentication (sovereign/virtual tier)
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def authenticate_with_tpm(
|
|
178
|
+
identity_id: str | None = None,
|
|
179
|
+
ak_handle: str | None = None,
|
|
180
|
+
api_base_url: str | None = None,
|
|
181
|
+
credentials: StoredCredentials | None = None,
|
|
182
|
+
) -> Token:
|
|
183
|
+
"""Authenticate using the TPM -- passwordless, zero-elevation sign-in.
|
|
184
|
+
|
|
185
|
+
This is the "OAuth for agents" flow:
|
|
186
|
+
1. Requests a challenge nonce from the server
|
|
187
|
+
2. Signs it with the TPM AK (no elevation needed)
|
|
188
|
+
3. Sends the signature back to the server
|
|
189
|
+
4. Server verifies and issues a JWT
|
|
190
|
+
|
|
191
|
+
No passwords, no client_secret transmitted, no UAC prompt.
|
|
192
|
+
The AK private key never leaves the TPM chip.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
identity_id: The 1id internal ID. If None, loaded from credentials.
|
|
196
|
+
ak_handle: The AK persistent handle (hex). If None, loaded from credentials.
|
|
197
|
+
api_base_url: Base URL for the 1id API. If None, loaded from credentials.
|
|
198
|
+
credentials: Pre-loaded credentials. If None, loaded from file.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
A valid Token object.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
NotEnrolledError: If no credentials file exists.
|
|
205
|
+
AuthenticationError: If the challenge-response fails.
|
|
206
|
+
NetworkError: If the server cannot be reached.
|
|
207
|
+
"""
|
|
208
|
+
global _cached_token
|
|
209
|
+
|
|
210
|
+
# Load credentials if not provided
|
|
211
|
+
if credentials is None:
|
|
212
|
+
credentials = load_credentials()
|
|
213
|
+
|
|
214
|
+
if identity_id is None:
|
|
215
|
+
identity_id = credentials.client_id # client_id IS the identity ID
|
|
216
|
+
|
|
217
|
+
if ak_handle is None:
|
|
218
|
+
ak_handle = credentials.hsm_key_reference
|
|
219
|
+
if not ak_handle:
|
|
220
|
+
raise AuthenticationError(
|
|
221
|
+
"No AK handle found in credentials. TPM authentication requires "
|
|
222
|
+
"a sovereign or virtual tier enrollment with a TPM."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if api_base_url is None:
|
|
226
|
+
api_base_url = credentials.api_base_url
|
|
227
|
+
|
|
228
|
+
# Step 1: Request a challenge nonce from the server
|
|
229
|
+
challenge_url = f"{api_base_url}/api/v1/auth/challenge"
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
with httpx.Client(timeout=TOKEN_REQUEST_TIMEOUT_SECONDS) as http_client:
|
|
233
|
+
challenge_response = http_client.post(
|
|
234
|
+
challenge_url,
|
|
235
|
+
json={"identity_id": identity_id},
|
|
236
|
+
headers={"User-Agent": "oneid-sdk-python/0.1.0"},
|
|
237
|
+
)
|
|
238
|
+
except httpx.ConnectError as connection_error:
|
|
239
|
+
raise NetworkError(
|
|
240
|
+
f"Could not connect to {challenge_url}: {connection_error}"
|
|
241
|
+
) from connection_error
|
|
242
|
+
except httpx.TimeoutException as timeout_error:
|
|
243
|
+
raise NetworkError(
|
|
244
|
+
f"Challenge request to {challenge_url} timed out: {timeout_error}"
|
|
245
|
+
) from timeout_error
|
|
246
|
+
|
|
247
|
+
if challenge_response.status_code != 200:
|
|
248
|
+
try:
|
|
249
|
+
error_body = challenge_response.json()
|
|
250
|
+
error_msg = error_body.get("error", {}).get("message", f"HTTP {challenge_response.status_code}")
|
|
251
|
+
except Exception:
|
|
252
|
+
error_msg = f"HTTP {challenge_response.status_code}"
|
|
253
|
+
raise AuthenticationError(f"Challenge request failed: {error_msg}")
|
|
254
|
+
|
|
255
|
+
challenge_data = challenge_response.json().get("data", {})
|
|
256
|
+
challenge_id = challenge_data.get("challenge_id")
|
|
257
|
+
nonce_b64 = challenge_data.get("nonce_b64")
|
|
258
|
+
|
|
259
|
+
if not challenge_id or not nonce_b64:
|
|
260
|
+
raise AuthenticationError("Server returned incomplete challenge response")
|
|
261
|
+
|
|
262
|
+
logger.debug("Received auth challenge: %s", challenge_id)
|
|
263
|
+
|
|
264
|
+
# Step 2: Sign the nonce with the TPM AK (NO elevation needed)
|
|
265
|
+
from .helper import sign_challenge_with_tpm
|
|
266
|
+
|
|
267
|
+
sign_result = sign_challenge_with_tpm(nonce_b64=nonce_b64, ak_handle=ak_handle)
|
|
268
|
+
signature_b64 = sign_result.get("signature_b64", "")
|
|
269
|
+
|
|
270
|
+
if not signature_b64:
|
|
271
|
+
raise AuthenticationError("TPM signing returned empty signature")
|
|
272
|
+
|
|
273
|
+
logger.debug("Nonce signed successfully, verifying with server...")
|
|
274
|
+
|
|
275
|
+
# Step 3: Send the signature to the server for verification
|
|
276
|
+
verify_url = f"{api_base_url}/api/v1/auth/verify"
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
with httpx.Client(timeout=TOKEN_REQUEST_TIMEOUT_SECONDS) as http_client:
|
|
280
|
+
verify_response = http_client.post(
|
|
281
|
+
verify_url,
|
|
282
|
+
json={
|
|
283
|
+
"challenge_id": challenge_id,
|
|
284
|
+
"signature_b64": signature_b64,
|
|
285
|
+
},
|
|
286
|
+
headers={"User-Agent": "oneid-sdk-python/0.1.0"},
|
|
287
|
+
)
|
|
288
|
+
except httpx.ConnectError as connection_error:
|
|
289
|
+
raise NetworkError(
|
|
290
|
+
f"Could not connect to {verify_url}: {connection_error}"
|
|
291
|
+
) from connection_error
|
|
292
|
+
except httpx.TimeoutException as timeout_error:
|
|
293
|
+
raise NetworkError(
|
|
294
|
+
f"Verify request to {verify_url} timed out: {timeout_error}"
|
|
295
|
+
) from timeout_error
|
|
296
|
+
|
|
297
|
+
if verify_response.status_code != 200:
|
|
298
|
+
try:
|
|
299
|
+
error_body = verify_response.json()
|
|
300
|
+
error_msg = error_body.get("error", {}).get("message", f"HTTP {verify_response.status_code}")
|
|
301
|
+
except Exception:
|
|
302
|
+
error_msg = f"HTTP {verify_response.status_code}"
|
|
303
|
+
raise AuthenticationError(f"TPM authentication failed: {error_msg}")
|
|
304
|
+
|
|
305
|
+
verify_data = verify_response.json().get("data", {})
|
|
306
|
+
|
|
307
|
+
if not verify_data.get("authenticated"):
|
|
308
|
+
raise AuthenticationError("Server did not confirm authentication")
|
|
309
|
+
|
|
310
|
+
# Extract token from response
|
|
311
|
+
tokens = verify_data.get("tokens")
|
|
312
|
+
if tokens and tokens.get("access_token"):
|
|
313
|
+
expires_in_seconds = tokens.get("expires_in", 3600)
|
|
314
|
+
token = Token(
|
|
315
|
+
access_token=tokens["access_token"],
|
|
316
|
+
token_type=tokens.get("token_type", "Bearer"),
|
|
317
|
+
expires_at=datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds),
|
|
318
|
+
refresh_token=tokens.get("refresh_token"),
|
|
319
|
+
)
|
|
320
|
+
_cached_token = token
|
|
321
|
+
logger.info(
|
|
322
|
+
"TPM authentication successful for %s (handle: %s)",
|
|
323
|
+
identity_id,
|
|
324
|
+
verify_data.get("identity", {}).get("handle", "?"),
|
|
325
|
+
)
|
|
326
|
+
return token
|
|
327
|
+
else:
|
|
328
|
+
raise AuthenticationError(
|
|
329
|
+
"TPM signature verified but no tokens were issued. "
|
|
330
|
+
"The Keycloak token endpoint may be unavailable."
|
|
331
|
+
)
|