noesis-auth 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.
- noesis_auth-0.1.0/PKG-INFO +122 -0
- noesis_auth-0.1.0/README.md +96 -0
- noesis_auth-0.1.0/__init__.py +27 -0
- noesis_auth-0.1.0/client.py +248 -0
- noesis_auth-0.1.0/fastapi_middleware.py +80 -0
- noesis_auth-0.1.0/jwt_validator.py +123 -0
- noesis_auth-0.1.0/models.py +22 -0
- noesis_auth-0.1.0/noesis_auth.egg-info/PKG-INFO +122 -0
- noesis_auth-0.1.0/noesis_auth.egg-info/SOURCES.txt +19 -0
- noesis_auth-0.1.0/noesis_auth.egg-info/dependency_links.txt +1 -0
- noesis_auth-0.1.0/noesis_auth.egg-info/requires.txt +5 -0
- noesis_auth-0.1.0/noesis_auth.egg-info/top_level.txt +1 -0
- noesis_auth-0.1.0/py.typed +0 -0
- noesis_auth-0.1.0/pyproject.toml +46 -0
- noesis_auth-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: noesis-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python Auth SDK for AI tool integration with the Noesis AIToolCenter platform
|
|
5
|
+
Author: Noesis AI Technologies
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Noesis-AI-Technologies/AIToolCenter
|
|
8
|
+
Project-URL: Documentation, https://github.com/Noesis-AI-Technologies/AIToolCenter/blob/main/docs/auth-sdk-guide.md
|
|
9
|
+
Project-URL: Repository, https://github.com/Noesis-AI-Technologies/AIToolCenter
|
|
10
|
+
Project-URL: Issues, https://github.com/Noesis-AI-Technologies/AIToolCenter/issues
|
|
11
|
+
Keywords: auth,oauth2,jwt,sdk,ai-tools
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: httpx>=0.24.0
|
|
23
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
24
|
+
Provides-Extra: fastapi
|
|
25
|
+
Requires-Dist: fastapi>=0.100.0; extra == "fastapi"
|
|
26
|
+
|
|
27
|
+
# noesis-auth (Python)
|
|
28
|
+
|
|
29
|
+
Python Auth SDK for AI tool integration with the [Noesis AIToolCenter](https://github.com/Noesis-AI-Technologies/AIToolCenter) platform.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install noesis-auth
|
|
35
|
+
|
|
36
|
+
# With FastAPI middleware support:
|
|
37
|
+
pip install noesis-auth[fastapi]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### JWT Validation (Tool-side)
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from auth_sdk import JWTValidator
|
|
46
|
+
|
|
47
|
+
validator = JWTValidator(
|
|
48
|
+
jwks_url="https://your-platform.com/.well-known/jwks.json"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
payload = await validator.validate(token)
|
|
52
|
+
print(payload["sub"]) # user ID
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### FastAPI Middleware
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from fastapi import Depends, FastAPI
|
|
59
|
+
from auth_sdk import AuthMiddleware, TokenPayload
|
|
60
|
+
|
|
61
|
+
app = FastAPI()
|
|
62
|
+
auth = AuthMiddleware(jwks_url="https://your-platform.com/.well-known/jwks.json")
|
|
63
|
+
|
|
64
|
+
@app.get("/api/generate")
|
|
65
|
+
async def generate(payload: TokenPayload = Depends(auth.require_tool("your-tool-id"))):
|
|
66
|
+
user_id = payload.sub
|
|
67
|
+
# ... your tool logic
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### OAuth2 Client (PKCE)
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from auth_sdk import AuthClient
|
|
74
|
+
|
|
75
|
+
client = AuthClient(base_url="https://your-platform.com")
|
|
76
|
+
|
|
77
|
+
# Generate PKCE pair
|
|
78
|
+
pkce = AuthClient.generate_pkce()
|
|
79
|
+
|
|
80
|
+
# Build authorization URL
|
|
81
|
+
url = client.build_authorize_url(
|
|
82
|
+
client_id="your-client-id",
|
|
83
|
+
redirect_uri="http://localhost:3000/callback",
|
|
84
|
+
code_challenge=pkce.code_challenge,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Exchange code for tokens
|
|
88
|
+
tokens = await client.exchange_code(
|
|
89
|
+
code=auth_code,
|
|
90
|
+
redirect_uri="http://localhost:3000/callback",
|
|
91
|
+
client_id="your-client-id",
|
|
92
|
+
code_verifier=pkce.code_verifier,
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Activation Code Redemption
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
result = await client.redeem_code(user_token="...", code="AXKF-M3PQ-7RBN-W2YT")
|
|
100
|
+
print(result.tool_id, result.expires_at)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Features
|
|
104
|
+
|
|
105
|
+
- **JWT Validation** — RS256 (JWKS) and HS256 (shared secret) with auto-detection
|
|
106
|
+
- **FastAPI Middleware** — Drop-in authentication and tool access verification
|
|
107
|
+
- **OAuth2 Client** — Authorization URL builder, code exchange, token refresh
|
|
108
|
+
- **PKCE Support** — S256 code challenge generation for public clients
|
|
109
|
+
- **Token Introspection** — Remote token validation endpoint
|
|
110
|
+
- **Activation Codes** — Redeem activation codes for tool entitlements
|
|
111
|
+
- **JWKS Caching** — 6-hour cache with stale-while-revalidate and retry on failure
|
|
112
|
+
|
|
113
|
+
## Requirements
|
|
114
|
+
|
|
115
|
+
- Python >= 3.11
|
|
116
|
+
- `httpx` >= 0.24
|
|
117
|
+
- `python-jose[cryptography]` >= 3.3
|
|
118
|
+
- `fastapi` >= 0.100 (optional, for middleware)
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# noesis-auth (Python)
|
|
2
|
+
|
|
3
|
+
Python Auth SDK for AI tool integration with the [Noesis AIToolCenter](https://github.com/Noesis-AI-Technologies/AIToolCenter) platform.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install noesis-auth
|
|
9
|
+
|
|
10
|
+
# With FastAPI middleware support:
|
|
11
|
+
pip install noesis-auth[fastapi]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
### JWT Validation (Tool-side)
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from auth_sdk import JWTValidator
|
|
20
|
+
|
|
21
|
+
validator = JWTValidator(
|
|
22
|
+
jwks_url="https://your-platform.com/.well-known/jwks.json"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
payload = await validator.validate(token)
|
|
26
|
+
print(payload["sub"]) # user ID
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### FastAPI Middleware
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from fastapi import Depends, FastAPI
|
|
33
|
+
from auth_sdk import AuthMiddleware, TokenPayload
|
|
34
|
+
|
|
35
|
+
app = FastAPI()
|
|
36
|
+
auth = AuthMiddleware(jwks_url="https://your-platform.com/.well-known/jwks.json")
|
|
37
|
+
|
|
38
|
+
@app.get("/api/generate")
|
|
39
|
+
async def generate(payload: TokenPayload = Depends(auth.require_tool("your-tool-id"))):
|
|
40
|
+
user_id = payload.sub
|
|
41
|
+
# ... your tool logic
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### OAuth2 Client (PKCE)
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from auth_sdk import AuthClient
|
|
48
|
+
|
|
49
|
+
client = AuthClient(base_url="https://your-platform.com")
|
|
50
|
+
|
|
51
|
+
# Generate PKCE pair
|
|
52
|
+
pkce = AuthClient.generate_pkce()
|
|
53
|
+
|
|
54
|
+
# Build authorization URL
|
|
55
|
+
url = client.build_authorize_url(
|
|
56
|
+
client_id="your-client-id",
|
|
57
|
+
redirect_uri="http://localhost:3000/callback",
|
|
58
|
+
code_challenge=pkce.code_challenge,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Exchange code for tokens
|
|
62
|
+
tokens = await client.exchange_code(
|
|
63
|
+
code=auth_code,
|
|
64
|
+
redirect_uri="http://localhost:3000/callback",
|
|
65
|
+
client_id="your-client-id",
|
|
66
|
+
code_verifier=pkce.code_verifier,
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Activation Code Redemption
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
result = await client.redeem_code(user_token="...", code="AXKF-M3PQ-7RBN-W2YT")
|
|
74
|
+
print(result.tool_id, result.expires_at)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Features
|
|
78
|
+
|
|
79
|
+
- **JWT Validation** — RS256 (JWKS) and HS256 (shared secret) with auto-detection
|
|
80
|
+
- **FastAPI Middleware** — Drop-in authentication and tool access verification
|
|
81
|
+
- **OAuth2 Client** — Authorization URL builder, code exchange, token refresh
|
|
82
|
+
- **PKCE Support** — S256 code challenge generation for public clients
|
|
83
|
+
- **Token Introspection** — Remote token validation endpoint
|
|
84
|
+
- **Activation Codes** — Redeem activation codes for tool entitlements
|
|
85
|
+
- **JWKS Caching** — 6-hour cache with stale-while-revalidate and retry on failure
|
|
86
|
+
|
|
87
|
+
## Requirements
|
|
88
|
+
|
|
89
|
+
- Python >= 3.11
|
|
90
|
+
- `httpx` >= 0.24
|
|
91
|
+
- `python-jose[cryptography]` >= 3.3
|
|
92
|
+
- `fastapi` >= 0.100 (optional, for middleware)
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from auth_sdk.client import AuthClient, AuthError, TokenResponse, RedeemResult, IntrospectResult, PKCEPair
|
|
2
|
+
from auth_sdk.jwt_validator import JWTValidator
|
|
3
|
+
from auth_sdk.models import Entitlement, TokenPayload
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from auth_sdk.fastapi_middleware import AuthMiddleware, require_tool_access
|
|
7
|
+
except ImportError:
|
|
8
|
+
# FastAPI is an optional dependency
|
|
9
|
+
AuthMiddleware = None # type: ignore[assignment, misc]
|
|
10
|
+
require_tool_access = None # type: ignore[assignment]
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"AuthClient",
|
|
17
|
+
"AuthError",
|
|
18
|
+
"AuthMiddleware",
|
|
19
|
+
"IntrospectResult",
|
|
20
|
+
"JWTValidator",
|
|
21
|
+
"PKCEPair",
|
|
22
|
+
"RedeemResult",
|
|
23
|
+
"TokenPayload",
|
|
24
|
+
"TokenResponse",
|
|
25
|
+
"Entitlement",
|
|
26
|
+
"require_tool_access",
|
|
27
|
+
]
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""AuthClient — HTTP client for interacting with the ERP platform APIs.
|
|
2
|
+
|
|
3
|
+
Implements TODO item B1 — Python SDK AuthClient (mirrors TypeScript SDK).
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
- build_authorize_url() — OAuth2 authorization URL builder
|
|
7
|
+
- exchange_code() — exchange auth code for tokens
|
|
8
|
+
- refresh_token() — refresh access token
|
|
9
|
+
- redeem_code() — redeem activation code
|
|
10
|
+
- introspect() — introspect token
|
|
11
|
+
- generate_pkce() — generate PKCE code verifier + challenge
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from urllib.parse import urlencode
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from auth_sdk.models import TokenPayload
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthError(Exception):
|
|
26
|
+
def __init__(self, message: str, code: str = "UNKNOWN", status: int = 0):
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.code = code
|
|
29
|
+
self.status = status
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class TokenResponse:
|
|
34
|
+
access_token: str
|
|
35
|
+
token_type: str
|
|
36
|
+
expires_in: int
|
|
37
|
+
refresh_token: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class RedeemResult:
|
|
42
|
+
tool_id: str
|
|
43
|
+
expires_at: str
|
|
44
|
+
message: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class IntrospectResult:
|
|
49
|
+
active: bool
|
|
50
|
+
sub: str | None = None
|
|
51
|
+
exp: int | None = None
|
|
52
|
+
iat: int | None = None
|
|
53
|
+
jti: str | None = None
|
|
54
|
+
entitlements: list[dict] | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class PKCEPair:
|
|
59
|
+
code_verifier: str
|
|
60
|
+
code_challenge: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _base64url_encode(data: bytes) -> str:
|
|
64
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AuthClient:
|
|
68
|
+
"""HTTP client for AIToolCenter platform APIs."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, base_url: str, timeout: float = 10.0):
|
|
71
|
+
self.base_url = base_url.rstrip("/")
|
|
72
|
+
self.timeout = timeout
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def generate_pkce() -> PKCEPair:
|
|
76
|
+
"""Generate a PKCE code verifier and S256 challenge."""
|
|
77
|
+
verifier_bytes = os.urandom(32)
|
|
78
|
+
code_verifier = _base64url_encode(verifier_bytes)
|
|
79
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
80
|
+
code_challenge = _base64url_encode(digest)
|
|
81
|
+
return PKCEPair(code_verifier=code_verifier, code_challenge=code_challenge)
|
|
82
|
+
|
|
83
|
+
def build_authorize_url(
|
|
84
|
+
self,
|
|
85
|
+
client_id: str,
|
|
86
|
+
redirect_uri: str,
|
|
87
|
+
state: str | None = None,
|
|
88
|
+
code_challenge: str | None = None,
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Build the OAuth2 authorization URL."""
|
|
91
|
+
params = {
|
|
92
|
+
"response_type": "code",
|
|
93
|
+
"client_id": client_id,
|
|
94
|
+
"redirect_uri": redirect_uri,
|
|
95
|
+
}
|
|
96
|
+
if state:
|
|
97
|
+
params["state"] = state
|
|
98
|
+
if code_challenge:
|
|
99
|
+
params["code_challenge"] = code_challenge
|
|
100
|
+
params["code_challenge_method"] = "S256"
|
|
101
|
+
return f"{self.base_url}/oauth/authorize?{urlencode(params)}"
|
|
102
|
+
|
|
103
|
+
async def exchange_code(
|
|
104
|
+
self,
|
|
105
|
+
code: str,
|
|
106
|
+
redirect_uri: str,
|
|
107
|
+
client_id: str,
|
|
108
|
+
code_verifier: str | None = None,
|
|
109
|
+
client_secret: str | None = None,
|
|
110
|
+
) -> TokenResponse:
|
|
111
|
+
"""Exchange an authorization code for tokens."""
|
|
112
|
+
data = {
|
|
113
|
+
"grant_type": "authorization_code",
|
|
114
|
+
"code": code,
|
|
115
|
+
"redirect_uri": redirect_uri,
|
|
116
|
+
"client_id": client_id,
|
|
117
|
+
}
|
|
118
|
+
if code_verifier:
|
|
119
|
+
data["code_verifier"] = code_verifier
|
|
120
|
+
if client_secret:
|
|
121
|
+
data["client_secret"] = client_secret
|
|
122
|
+
|
|
123
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
124
|
+
resp = await client.post(
|
|
125
|
+
f"{self.base_url}/oauth/token",
|
|
126
|
+
data=data,
|
|
127
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if resp.status_code != 200:
|
|
131
|
+
body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
132
|
+
raise AuthError(
|
|
133
|
+
body.get("detail", f"Code exchange failed: {resp.status_code}"),
|
|
134
|
+
"TOKEN_INVALID",
|
|
135
|
+
resp.status_code,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
d = resp.json()
|
|
139
|
+
return TokenResponse(
|
|
140
|
+
access_token=d["access_token"],
|
|
141
|
+
token_type=d.get("token_type", "bearer"),
|
|
142
|
+
expires_in=d.get("expires_in", 0),
|
|
143
|
+
refresh_token=d.get("refresh_token"),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
async def refresh_token(
|
|
147
|
+
self,
|
|
148
|
+
refresh_token: str,
|
|
149
|
+
client_id: str | None = None,
|
|
150
|
+
client_secret: str | None = None,
|
|
151
|
+
) -> TokenResponse:
|
|
152
|
+
"""Refresh an access token using a refresh token."""
|
|
153
|
+
data = {
|
|
154
|
+
"grant_type": "refresh_token",
|
|
155
|
+
"refresh_token": refresh_token,
|
|
156
|
+
}
|
|
157
|
+
if client_id:
|
|
158
|
+
data["client_id"] = client_id
|
|
159
|
+
if client_secret:
|
|
160
|
+
data["client_secret"] = client_secret
|
|
161
|
+
|
|
162
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
163
|
+
resp = await client.post(
|
|
164
|
+
f"{self.base_url}/oauth/token",
|
|
165
|
+
data=data,
|
|
166
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if resp.status_code != 200:
|
|
170
|
+
body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
171
|
+
raise AuthError(
|
|
172
|
+
body.get("detail", f"Token refresh failed: {resp.status_code}"),
|
|
173
|
+
"TOKEN_INVALID",
|
|
174
|
+
resp.status_code,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
d = resp.json()
|
|
178
|
+
return TokenResponse(
|
|
179
|
+
access_token=d["access_token"],
|
|
180
|
+
token_type=d.get("token_type", "bearer"),
|
|
181
|
+
expires_in=d.get("expires_in", 0),
|
|
182
|
+
refresh_token=d.get("refresh_token"),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def redeem_code(self, user_token: str, code: str) -> RedeemResult:
|
|
186
|
+
"""Redeem an activation code for the authenticated user."""
|
|
187
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
188
|
+
resp = await client.post(
|
|
189
|
+
f"{self.base_url}/api/v1/activation/redeem",
|
|
190
|
+
json={"code": code},
|
|
191
|
+
headers={
|
|
192
|
+
"Authorization": f"Bearer {user_token}",
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if resp.status_code != 200:
|
|
198
|
+
body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
199
|
+
raise AuthError(
|
|
200
|
+
body.get("detail", f"Redemption failed: {resp.status_code}"),
|
|
201
|
+
"TOKEN_INVALID",
|
|
202
|
+
resp.status_code,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
d = resp.json()
|
|
206
|
+
return RedeemResult(
|
|
207
|
+
tool_id=d["tool_id"],
|
|
208
|
+
expires_at=d["expires_at"],
|
|
209
|
+
message=d.get("message", ""),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def introspect(
|
|
213
|
+
self,
|
|
214
|
+
token: str,
|
|
215
|
+
client_id: str | None = None,
|
|
216
|
+
client_secret: str | None = None,
|
|
217
|
+
) -> IntrospectResult:
|
|
218
|
+
"""Introspect a token to check if it's active."""
|
|
219
|
+
data = {"token": token}
|
|
220
|
+
if client_id:
|
|
221
|
+
data["client_id"] = client_id
|
|
222
|
+
if client_secret:
|
|
223
|
+
data["client_secret"] = client_secret
|
|
224
|
+
|
|
225
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
226
|
+
resp = await client.post(
|
|
227
|
+
f"{self.base_url}/oauth/introspect",
|
|
228
|
+
data=data,
|
|
229
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if resp.status_code != 200:
|
|
233
|
+
body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
234
|
+
raise AuthError(
|
|
235
|
+
body.get("detail", f"Introspection failed: {resp.status_code}"),
|
|
236
|
+
"NETWORK_ERROR",
|
|
237
|
+
resp.status_code,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
d = resp.json()
|
|
241
|
+
return IntrospectResult(
|
|
242
|
+
active=d.get("active", False),
|
|
243
|
+
sub=d.get("sub"),
|
|
244
|
+
exp=d.get("exp"),
|
|
245
|
+
iat=d.get("iat"),
|
|
246
|
+
jti=d.get("jti"),
|
|
247
|
+
entitlements=d.get("entitlements"),
|
|
248
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import time
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException, Request, status
|
|
6
|
+
|
|
7
|
+
from auth_sdk.jwt_validator import JWTValidator
|
|
8
|
+
from auth_sdk.models import Entitlement, TokenPayload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthMiddleware:
|
|
12
|
+
"""FastAPI dependency for tool-side authentication and authorization."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, jwks_url: str, algorithm: str = "RS256", hs256_secret: str | None = None):
|
|
15
|
+
self.validator = JWTValidator(
|
|
16
|
+
jwks_url=jwks_url,
|
|
17
|
+
algorithm=algorithm,
|
|
18
|
+
hs256_secret=hs256_secret,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async def authenticate(self, request: Request) -> TokenPayload:
|
|
22
|
+
"""Extract and validate the bearer token, return parsed payload."""
|
|
23
|
+
auth_header = request.headers.get("Authorization", "")
|
|
24
|
+
if not auth_header.startswith("Bearer "):
|
|
25
|
+
raise HTTPException(
|
|
26
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
27
|
+
detail="Missing or invalid Authorization header",
|
|
28
|
+
)
|
|
29
|
+
token = auth_header[7:]
|
|
30
|
+
try:
|
|
31
|
+
raw = await self.validator.validate(token)
|
|
32
|
+
except ValueError as e:
|
|
33
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
|
|
34
|
+
|
|
35
|
+
entitlements = [
|
|
36
|
+
Entitlement(tool_id=e["tool_id"], expires_at=e["expires_at"]) for e in raw.get("entitlements", [])
|
|
37
|
+
]
|
|
38
|
+
return TokenPayload(
|
|
39
|
+
sub=raw["sub"],
|
|
40
|
+
exp=raw["exp"],
|
|
41
|
+
iat=raw["iat"],
|
|
42
|
+
jti=raw.get("jti", ""),
|
|
43
|
+
entitlements=entitlements,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def require_tool(self, tool_id: str | int) -> Callable:
|
|
47
|
+
"""FastAPI dependency that checks the user has access to a specific tool."""
|
|
48
|
+
middleware = self
|
|
49
|
+
|
|
50
|
+
async def dependency(request: Request) -> TokenPayload:
|
|
51
|
+
payload = await middleware.authenticate(request)
|
|
52
|
+
if not payload.has_tool_access(tool_id):
|
|
53
|
+
raise HTTPException(
|
|
54
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
55
|
+
detail=f"No access to tool {tool_id}",
|
|
56
|
+
)
|
|
57
|
+
return payload
|
|
58
|
+
|
|
59
|
+
return dependency
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def require_tool_access(tool_id: str | int, *, jwks_url: str = "", _middleware_cache: dict = {}):
|
|
63
|
+
"""Convenience function that returns a FastAPI dependency for tool access verification.
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
from auth_sdk import require_tool_access
|
|
67
|
+
|
|
68
|
+
@app.get("/api/generate")
|
|
69
|
+
async def generate(
|
|
70
|
+
payload: TokenPayload = Depends(require_tool_access(1, jwks_url="https://auth.example.com/.well-known/jwks.json"))
|
|
71
|
+
):
|
|
72
|
+
user_id = payload.sub
|
|
73
|
+
...
|
|
74
|
+
"""
|
|
75
|
+
if not jwks_url:
|
|
76
|
+
raise ValueError("jwks_url is required for require_tool_access()")
|
|
77
|
+
if jwks_url not in _middleware_cache:
|
|
78
|
+
_middleware_cache[jwks_url] = AuthMiddleware(jwks_url=jwks_url)
|
|
79
|
+
middleware = _middleware_cache[jwks_url]
|
|
80
|
+
return middleware.require_tool(tool_id)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from jose import jwt, JWTError, ExpiredSignatureError
|
|
8
|
+
from jose.constants import ALGORITHMS
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("auth_sdk")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JWTValidator:
|
|
14
|
+
"""Validates JWTs using JWKS fetched from the Auth Center.
|
|
15
|
+
|
|
16
|
+
Supports RS256 (via JWKS) and HS256 (via shared secret).
|
|
17
|
+
When using HS256, pass the shared secret via ``hs256_secret``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
jwks_url: str,
|
|
23
|
+
algorithm: str = "RS256",
|
|
24
|
+
cache_ttl: int = 21600,
|
|
25
|
+
hs256_secret: str | None = None,
|
|
26
|
+
):
|
|
27
|
+
self.jwks_url = jwks_url
|
|
28
|
+
self.algorithm = algorithm
|
|
29
|
+
self.cache_ttl = cache_ttl # default 6 hours
|
|
30
|
+
self.hs256_secret = hs256_secret
|
|
31
|
+
self._jwks: dict[str, Any] | None = None
|
|
32
|
+
self._jwks_fetched_at: float = 0
|
|
33
|
+
|
|
34
|
+
async def _fetch_jwks(self) -> dict:
|
|
35
|
+
"""Fetch JWKS from the auth server with one retry on failure."""
|
|
36
|
+
last_error: Exception | None = None
|
|
37
|
+
for attempt in range(2):
|
|
38
|
+
try:
|
|
39
|
+
async with httpx.AsyncClient() as client:
|
|
40
|
+
resp = await client.get(self.jwks_url, timeout=10)
|
|
41
|
+
resp.raise_for_status()
|
|
42
|
+
return resp.json()
|
|
43
|
+
except Exception as e:
|
|
44
|
+
last_error = e
|
|
45
|
+
if attempt == 0:
|
|
46
|
+
logger.warning("JWKS fetch attempt %d failed: %s, retrying...", attempt + 1, e)
|
|
47
|
+
await asyncio.sleep(1)
|
|
48
|
+
raise RuntimeError(f"Failed to fetch JWKS from {self.jwks_url}: {last_error}") from last_error
|
|
49
|
+
|
|
50
|
+
async def get_jwks(self) -> dict:
|
|
51
|
+
now = time.time()
|
|
52
|
+
if self._jwks is None or (now - self._jwks_fetched_at) > self.cache_ttl:
|
|
53
|
+
try:
|
|
54
|
+
self._jwks = await self._fetch_jwks()
|
|
55
|
+
self._jwks_fetched_at = now
|
|
56
|
+
logger.info("JWKS refreshed from %s", self.jwks_url)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
if self._jwks is not None:
|
|
59
|
+
logger.warning("Failed to refresh JWKS, using cached: %s", e)
|
|
60
|
+
else:
|
|
61
|
+
raise RuntimeError(f"Cannot validate tokens: JWKS unavailable from {self.jwks_url}") from e
|
|
62
|
+
return self._jwks
|
|
63
|
+
|
|
64
|
+
async def validate(self, token: str) -> dict:
|
|
65
|
+
"""Validate JWT signature and expiry. Returns decoded payload.
|
|
66
|
+
|
|
67
|
+
Auto-detects the algorithm from the JWT header:
|
|
68
|
+
- If alg=HS256 and hs256_secret is configured → symmetric validation
|
|
69
|
+
- If alg=RS256 → asymmetric validation via JWKS
|
|
70
|
+
- Falls back to configured self.algorithm if header detection fails
|
|
71
|
+
"""
|
|
72
|
+
# Auto-detect algorithm from token header
|
|
73
|
+
try:
|
|
74
|
+
unverified_header = jwt.get_unverified_header(token)
|
|
75
|
+
token_alg = unverified_header.get("alg", self.algorithm)
|
|
76
|
+
except JWTError:
|
|
77
|
+
token_alg = self.algorithm
|
|
78
|
+
|
|
79
|
+
if token_alg == "HS256" and self.hs256_secret:
|
|
80
|
+
# Symmetric validation — no JWKS needed
|
|
81
|
+
try:
|
|
82
|
+
payload = jwt.decode(
|
|
83
|
+
token,
|
|
84
|
+
self.hs256_secret,
|
|
85
|
+
algorithms=["HS256"],
|
|
86
|
+
options={"verify_aud": False},
|
|
87
|
+
)
|
|
88
|
+
return payload
|
|
89
|
+
except ExpiredSignatureError:
|
|
90
|
+
raise ValueError("Token has expired")
|
|
91
|
+
except JWTError as e:
|
|
92
|
+
raise ValueError(f"Token validation failed: {e}") from e
|
|
93
|
+
|
|
94
|
+
# Asymmetric validation via JWKS
|
|
95
|
+
jwks = await self.get_jwks()
|
|
96
|
+
|
|
97
|
+
# If JWKS returns empty keys and we have an hs256_secret, fall back
|
|
98
|
+
if not jwks.get("keys") and self.hs256_secret:
|
|
99
|
+
try:
|
|
100
|
+
payload = jwt.decode(
|
|
101
|
+
token,
|
|
102
|
+
self.hs256_secret,
|
|
103
|
+
algorithms=["HS256"],
|
|
104
|
+
options={"verify_aud": False},
|
|
105
|
+
)
|
|
106
|
+
return payload
|
|
107
|
+
except ExpiredSignatureError:
|
|
108
|
+
raise ValueError("Token has expired")
|
|
109
|
+
except JWTError as e:
|
|
110
|
+
raise ValueError(f"Token validation failed: {e}") from e
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
payload = jwt.decode(
|
|
114
|
+
token,
|
|
115
|
+
jwks,
|
|
116
|
+
algorithms=[self.algorithm],
|
|
117
|
+
options={"verify_aud": False},
|
|
118
|
+
)
|
|
119
|
+
return payload
|
|
120
|
+
except ExpiredSignatureError:
|
|
121
|
+
raise ValueError("Token has expired")
|
|
122
|
+
except JWTError as e:
|
|
123
|
+
raise ValueError(f"Token validation failed: {e}") from e
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Entitlement:
|
|
6
|
+
tool_id: str
|
|
7
|
+
expires_at: int # unix timestamp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class TokenPayload:
|
|
12
|
+
sub: str
|
|
13
|
+
exp: int
|
|
14
|
+
iat: int
|
|
15
|
+
jti: str
|
|
16
|
+
entitlements: list[Entitlement] = field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
def has_tool_access(self, tool_id: str) -> bool:
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
now = int(time.time())
|
|
22
|
+
return any((e.tool_id == str(tool_id) and e.expires_at > now) for e in self.entitlements)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: noesis-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python Auth SDK for AI tool integration with the Noesis AIToolCenter platform
|
|
5
|
+
Author: Noesis AI Technologies
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Noesis-AI-Technologies/AIToolCenter
|
|
8
|
+
Project-URL: Documentation, https://github.com/Noesis-AI-Technologies/AIToolCenter/blob/main/docs/auth-sdk-guide.md
|
|
9
|
+
Project-URL: Repository, https://github.com/Noesis-AI-Technologies/AIToolCenter
|
|
10
|
+
Project-URL: Issues, https://github.com/Noesis-AI-Technologies/AIToolCenter/issues
|
|
11
|
+
Keywords: auth,oauth2,jwt,sdk,ai-tools
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: httpx>=0.24.0
|
|
23
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
24
|
+
Provides-Extra: fastapi
|
|
25
|
+
Requires-Dist: fastapi>=0.100.0; extra == "fastapi"
|
|
26
|
+
|
|
27
|
+
# noesis-auth (Python)
|
|
28
|
+
|
|
29
|
+
Python Auth SDK for AI tool integration with the [Noesis AIToolCenter](https://github.com/Noesis-AI-Technologies/AIToolCenter) platform.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install noesis-auth
|
|
35
|
+
|
|
36
|
+
# With FastAPI middleware support:
|
|
37
|
+
pip install noesis-auth[fastapi]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### JWT Validation (Tool-side)
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from auth_sdk import JWTValidator
|
|
46
|
+
|
|
47
|
+
validator = JWTValidator(
|
|
48
|
+
jwks_url="https://your-platform.com/.well-known/jwks.json"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
payload = await validator.validate(token)
|
|
52
|
+
print(payload["sub"]) # user ID
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### FastAPI Middleware
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from fastapi import Depends, FastAPI
|
|
59
|
+
from auth_sdk import AuthMiddleware, TokenPayload
|
|
60
|
+
|
|
61
|
+
app = FastAPI()
|
|
62
|
+
auth = AuthMiddleware(jwks_url="https://your-platform.com/.well-known/jwks.json")
|
|
63
|
+
|
|
64
|
+
@app.get("/api/generate")
|
|
65
|
+
async def generate(payload: TokenPayload = Depends(auth.require_tool("your-tool-id"))):
|
|
66
|
+
user_id = payload.sub
|
|
67
|
+
# ... your tool logic
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### OAuth2 Client (PKCE)
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from auth_sdk import AuthClient
|
|
74
|
+
|
|
75
|
+
client = AuthClient(base_url="https://your-platform.com")
|
|
76
|
+
|
|
77
|
+
# Generate PKCE pair
|
|
78
|
+
pkce = AuthClient.generate_pkce()
|
|
79
|
+
|
|
80
|
+
# Build authorization URL
|
|
81
|
+
url = client.build_authorize_url(
|
|
82
|
+
client_id="your-client-id",
|
|
83
|
+
redirect_uri="http://localhost:3000/callback",
|
|
84
|
+
code_challenge=pkce.code_challenge,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Exchange code for tokens
|
|
88
|
+
tokens = await client.exchange_code(
|
|
89
|
+
code=auth_code,
|
|
90
|
+
redirect_uri="http://localhost:3000/callback",
|
|
91
|
+
client_id="your-client-id",
|
|
92
|
+
code_verifier=pkce.code_verifier,
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Activation Code Redemption
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
result = await client.redeem_code(user_token="...", code="AXKF-M3PQ-7RBN-W2YT")
|
|
100
|
+
print(result.tool_id, result.expires_at)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Features
|
|
104
|
+
|
|
105
|
+
- **JWT Validation** — RS256 (JWKS) and HS256 (shared secret) with auto-detection
|
|
106
|
+
- **FastAPI Middleware** — Drop-in authentication and tool access verification
|
|
107
|
+
- **OAuth2 Client** — Authorization URL builder, code exchange, token refresh
|
|
108
|
+
- **PKCE Support** — S256 code challenge generation for public clients
|
|
109
|
+
- **Token Introspection** — Remote token validation endpoint
|
|
110
|
+
- **Activation Codes** — Redeem activation codes for tool entitlements
|
|
111
|
+
- **JWKS Caching** — 6-hour cache with stale-while-revalidate and retry on failure
|
|
112
|
+
|
|
113
|
+
## Requirements
|
|
114
|
+
|
|
115
|
+
- Python >= 3.11
|
|
116
|
+
- `httpx` >= 0.24
|
|
117
|
+
- `python-jose[cryptography]` >= 3.3
|
|
118
|
+
- `fastapi` >= 0.100 (optional, for middleware)
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
__init__.py
|
|
3
|
+
client.py
|
|
4
|
+
fastapi_middleware.py
|
|
5
|
+
jwt_validator.py
|
|
6
|
+
models.py
|
|
7
|
+
py.typed
|
|
8
|
+
pyproject.toml
|
|
9
|
+
./__init__.py
|
|
10
|
+
./client.py
|
|
11
|
+
./fastapi_middleware.py
|
|
12
|
+
./jwt_validator.py
|
|
13
|
+
./models.py
|
|
14
|
+
./py.typed
|
|
15
|
+
noesis_auth.egg-info/PKG-INFO
|
|
16
|
+
noesis_auth.egg-info/SOURCES.txt
|
|
17
|
+
noesis_auth.egg-info/dependency_links.txt
|
|
18
|
+
noesis_auth.egg-info/requires.txt
|
|
19
|
+
noesis_auth.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
auth_sdk
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "noesis-auth"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python Auth SDK for AI tool integration with the Noesis AIToolCenter platform"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Noesis AI Technologies" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["auth", "oauth2", "jwt", "sdk", "ai-tools"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Topic :: Security",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"httpx>=0.24.0",
|
|
24
|
+
"python-jose[cryptography]>=3.3.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
fastapi = [
|
|
29
|
+
"fastapi>=0.100.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/Noesis-AI-Technologies/AIToolCenter"
|
|
34
|
+
Documentation = "https://github.com/Noesis-AI-Technologies/AIToolCenter/blob/main/docs/auth-sdk-guide.md"
|
|
35
|
+
Repository = "https://github.com/Noesis-AI-Technologies/AIToolCenter"
|
|
36
|
+
Issues = "https://github.com/Noesis-AI-Technologies/AIToolCenter/issues"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["setuptools>=68.0"]
|
|
40
|
+
build-backend = "setuptools.build_meta"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools]
|
|
43
|
+
packages = ["auth_sdk"]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.package-dir]
|
|
46
|
+
auth_sdk = "."
|