auth-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
auth_ai/__init__.py ADDED
@@ -0,0 +1,127 @@
1
+ """Python SDK for applications relying on a CIRCLE auth-service identity provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from .ai import AiClient, UserAi
8
+ from .claims import (
9
+ AccessTokenClaims,
10
+ FeatureValue,
11
+ IdTokenClaims,
12
+ get_feature,
13
+ has_feature,
14
+ has_permission,
15
+ has_role,
16
+ )
17
+ from .client import AuthClient
18
+ from .config import DEFAULT_SCOPES, WALLET_SELF_SCOPE, AuthClientConfig
19
+ from .errors import (
20
+ ERR,
21
+ AuthAiError,
22
+ AuthAiErrorCode,
23
+ AuthAiErrorDetails,
24
+ is_auth_ai_error_code,
25
+ )
26
+ from .gateway import (
27
+ GatewayAgent,
28
+ GatewayAuth,
29
+ GatewayClient,
30
+ GatewayMcpServer,
31
+ GatewayModel,
32
+ )
33
+ from .gateway_errors import (
34
+ is_insufficient_credit,
35
+ is_insufficient_credit_status,
36
+ to_gateway_error,
37
+ )
38
+ from .grants import (
39
+ AccessToken,
40
+ GrantLock,
41
+ GrantManager,
42
+ GrantStore,
43
+ InProcessGrantLock,
44
+ MemoryGrantStore,
45
+ )
46
+ from .introspection import IntrospectionResult
47
+ from .session_user import SessionUser, to_session_user
48
+ from .types import (
49
+ LoginHandshake,
50
+ LoginRequest,
51
+ LoginResult,
52
+ Prompt,
53
+ TokenSet,
54
+ TokenTypeHint,
55
+ )
56
+ from .wallet import (
57
+ MICRO_PER_EUR,
58
+ TopupOptions,
59
+ WalletAccount,
60
+ WalletClient,
61
+ WalletSessionUsage,
62
+ WalletStats,
63
+ WalletTransaction,
64
+ WalletTransactionPage,
65
+ WalletTransactionType,
66
+ format_euros,
67
+ )
68
+
69
+ try:
70
+ __version__ = version("auth-ai")
71
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
72
+ __version__ = "0.0.0"
73
+
74
+ __all__ = [
75
+ "DEFAULT_SCOPES",
76
+ "ERR",
77
+ "MICRO_PER_EUR",
78
+ "WALLET_SELF_SCOPE",
79
+ "AccessToken",
80
+ "AccessTokenClaims",
81
+ "AiClient",
82
+ "AuthAiError",
83
+ "AuthAiErrorCode",
84
+ "AuthAiErrorDetails",
85
+ "AuthClient",
86
+ "AuthClientConfig",
87
+ "FeatureValue",
88
+ "GatewayAgent",
89
+ "GatewayAuth",
90
+ "GatewayClient",
91
+ "GatewayMcpServer",
92
+ "GatewayModel",
93
+ "GrantLock",
94
+ "GrantManager",
95
+ "GrantStore",
96
+ "IdTokenClaims",
97
+ "InProcessGrantLock",
98
+ "IntrospectionResult",
99
+ "LoginHandshake",
100
+ "LoginRequest",
101
+ "LoginResult",
102
+ "MemoryGrantStore",
103
+ "Prompt",
104
+ "SessionUser",
105
+ "TokenSet",
106
+ "TokenTypeHint",
107
+ "TopupOptions",
108
+ "UserAi",
109
+ "WalletAccount",
110
+ "WalletClient",
111
+ "WalletSessionUsage",
112
+ "WalletStats",
113
+ "WalletTransaction",
114
+ "WalletTransactionPage",
115
+ "WalletTransactionType",
116
+ "__version__",
117
+ "format_euros",
118
+ "get_feature",
119
+ "has_feature",
120
+ "has_permission",
121
+ "has_role",
122
+ "is_auth_ai_error_code",
123
+ "is_insufficient_credit",
124
+ "is_insufficient_credit_status",
125
+ "to_gateway_error",
126
+ "to_session_user",
127
+ ]
auth_ai/ai.py ADDED
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Protocol
4
+
5
+ from .errors import ERR
6
+ from .gateway import GatewayClient
7
+ from .http import JsonHttp
8
+ from .wallet import WalletClient
9
+
10
+ if TYPE_CHECKING:
11
+ import httpx
12
+
13
+ from .config import AuthClientConfig
14
+ from .grants import AccessToken
15
+
16
+
17
+ class AiAuth(Protocol):
18
+ @property
19
+ def config(self) -> AuthClientConfig: ...
20
+
21
+ @property
22
+ def http(self) -> httpx.AsyncClient: ...
23
+
24
+
25
+ class AiGrants(Protocol):
26
+ async def get_access_token(
27
+ self, grant_id: str, *, resource: str | None = None
28
+ ) -> AccessToken: ...
29
+
30
+ def invalidate(self, grant_id: str, resource: str | None = None) -> None: ...
31
+
32
+
33
+ class _AudienceTokens:
34
+ """Mints the access token of one user for one audience, from the grant manager.
35
+
36
+ Serves both the wallet (a single audience, so the key is ignored) and the gateway.
37
+ """
38
+
39
+ def __init__(self, grants: AiGrants, grant_id: str, resource: str) -> None:
40
+ self._grants = grants
41
+ self._grant_id = grant_id
42
+ self._resource = resource
43
+
44
+ async def get_token(self, key: str | None = None) -> str:
45
+ token = await self._grants.get_access_token(self._grant_id, resource=self._resource)
46
+ return token.access_token
47
+
48
+ def invalidate(self, key: str | None = None) -> None:
49
+ self._grants.invalidate(self._grant_id, self._resource)
50
+
51
+
52
+ class UserAi:
53
+ """What an application does for one signed-in user: read the wallet, call the gateway."""
54
+
55
+ def __init__(self, auth: AiAuth, grants: AiGrants, grant_id: str) -> None:
56
+ self._auth = auth
57
+ config = auth.config
58
+ self.wallet = WalletClient(
59
+ JsonHttp(
60
+ base_url=config.issuer,
61
+ http=auth.http,
62
+ timeout_seconds=config.timeout_seconds,
63
+ tokens=_AudienceTokens(grants, grant_id, config.issuer),
64
+ ),
65
+ config.issuer,
66
+ )
67
+ self._gateway_tokens = (
68
+ _AudienceTokens(grants, grant_id, config.gateway_url) if config.gateway_url else None
69
+ )
70
+ self._gateway: GatewayClient | None = None
71
+
72
+ @property
73
+ def gateway(self) -> GatewayClient:
74
+ """Needs `gateway_url` in the client configuration."""
75
+ config = self._auth.config
76
+ if not config.gateway_url or self._gateway_tokens is None:
77
+ raise ERR.CFG_001("The gateway requires `gateway_url` in the configuration")
78
+ if self._gateway is None:
79
+ self._gateway = GatewayClient(
80
+ gateway_url=config.gateway_url,
81
+ tokens=self._gateway_tokens,
82
+ http=self._auth.http,
83
+ timeout_seconds=config.timeout_seconds,
84
+ )
85
+ return self._gateway
86
+
87
+
88
+ class AiClient:
89
+ """Wallet and gateway clients bound to a user's grant.
90
+
91
+ Each call mints (or reuses) a short-lived access token for the right audience:
92
+ the wallet is served by auth-service, the gateway by its own URL.
93
+ """
94
+
95
+ def __init__(self, *, auth: AiAuth, grants: AiGrants) -> None:
96
+ self._auth = auth
97
+ self._grants = grants
98
+
99
+ def for_user(self, grant_id: str) -> UserAi:
100
+ """`grant_id` is the key the user's refresh token was saved under (typically `sub`)."""
101
+ return UserAi(self._auth, self._grants, grant_id)
auth_ai/claims.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal, Protocol
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ type FeatureValue = str | int | float | bool
8
+ type PermissionAction = Literal["read", "write"]
9
+
10
+
11
+ class _Claims(BaseModel):
12
+ # Unknown claims are kept as is.
13
+ model_config = ConfigDict(extra="allow", frozen=True)
14
+
15
+ iss: str
16
+ sub: str
17
+ aud: str | list[str]
18
+ exp: int | float
19
+ iat: int | float
20
+ # Present only when the matching scope was granted.
21
+ roles: list[str] | None = None
22
+ permissions: list[str] | None = None
23
+ features: dict[str, FeatureValue] | None = None
24
+ plan: str | None = None
25
+ org_id: str | None = None
26
+ sid: str | None = None
27
+ azp: str | None = None
28
+
29
+
30
+ class IdTokenClaims(_Claims):
31
+ """Claims of an ID token issued by auth-service."""
32
+
33
+ name: str | None = None
34
+ picture: str | None = None
35
+ given_name: str | None = None
36
+ family_name: str | None = None
37
+ company: str | None = None
38
+ updated_at: int | float | None = None
39
+ email: str | None = None
40
+ email_verified: bool | None = None
41
+ phone_number: str | None = None
42
+
43
+
44
+ class AccessTokenClaims(_Claims):
45
+ """Claims of a JWT access token.
46
+
47
+ `azp` is the OAuth client that obtained the token and `scope` is a
48
+ space-separated list.
49
+ """
50
+
51
+ azp: str
52
+ scope: str | None = None
53
+
54
+
55
+ class EntitlementClaims(Protocol):
56
+ @property
57
+ def roles(self) -> list[str] | None: ...
58
+ @property
59
+ def permissions(self) -> list[str] | None: ...
60
+ @property
61
+ def features(self) -> dict[str, FeatureValue] | None: ...
62
+
63
+
64
+ def has_role(claims: EntitlementClaims, role: str) -> bool:
65
+ return role in (claims.roles or ())
66
+
67
+
68
+ def has_permission(
69
+ claims: EntitlementClaims, resource: str, action: PermissionAction = "read"
70
+ ) -> bool:
71
+ """`resource` grants read access and `resource.write` grants write access,
72
+ which also implies read: auth-service keeps only the highest action."""
73
+ permissions = claims.permissions
74
+ if not permissions:
75
+ return False
76
+ if f"{resource}.write" in permissions:
77
+ return True
78
+ return action == "read" and resource in permissions
79
+
80
+
81
+ def has_feature(claims: EntitlementClaims, key: str) -> bool:
82
+ """A feature is enabled when present and neither `false` nor `0` nor `""`."""
83
+ value = get_feature(claims, key)
84
+ return value is not None and value not in {"", 0}
85
+
86
+
87
+ def get_feature(claims: EntitlementClaims, key: str) -> FeatureValue | None:
88
+ return (claims.features or {}).get(key)
auth_ai/client.py ADDED
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import secrets
7
+ from typing import TYPE_CHECKING, Any, Self
8
+ from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
9
+
10
+ import httpx
11
+ from pydantic import ValidationError
12
+
13
+ from .claims import IdTokenClaims
14
+ from .config import AuthClientConfig
15
+ from .errors import ERR
16
+ from .id_token import check_id_token_binding
17
+ from .introspection import IntrospectionResult
18
+ from .provider import Provider, ProviderLoader
19
+ from .token_endpoint import ProviderEndpoints
20
+ from .types import (
21
+ LoginHandshake,
22
+ LoginRequest,
23
+ LoginResult,
24
+ Prompt,
25
+ TokenSet,
26
+ TokenTypeHint,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Mapping, Sequence
31
+ from types import TracebackType
32
+
33
+ _PKCE_VERIFIER_BYTES = 48
34
+ _RANDOM_BYTES = 32
35
+
36
+
37
+ def _s256_challenge(code_verifier: str) -> str:
38
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
39
+ return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
40
+
41
+
42
+ class AuthClient:
43
+ """OIDC relying party for auth-service.
44
+
45
+ Authorization-code grant with PKCE, refresh with RFC 8707 resource narrowing,
46
+ logout, revocation and introspection. It keeps no session: callers persist the
47
+ handshake, the tokens and the claims where it suits them.
48
+
49
+ `http_client` replaces the default `httpx.AsyncClient`, e.g. to add a proxy, a
50
+ transport or an observability layer. A client given by the caller is never
51
+ closed by the SDK; otherwise call `aclose()` (or use `async with`).
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ config: AuthClientConfig | Mapping[str, Any],
57
+ *,
58
+ http_client: httpx.AsyncClient | None = None,
59
+ ) -> None:
60
+ self.config = (
61
+ config
62
+ if isinstance(config, AuthClientConfig)
63
+ else AuthClientConfig.model_validate(config)
64
+ )
65
+ self._owns_http = http_client is None
66
+ #: The client used for every request, shared with the REST API clients built on this one.
67
+ self.http = http_client or httpx.AsyncClient(
68
+ timeout=self.config.timeout_seconds, follow_redirects=False
69
+ )
70
+ self._provider = ProviderLoader(self.config, self.http)
71
+ self._endpoints = ProviderEndpoints(self.config, self.http)
72
+
73
+ async def __aenter__(self) -> Self:
74
+ return self
75
+
76
+ async def __aexit__(
77
+ self,
78
+ exc_type: type[BaseException] | None,
79
+ exc: BaseException | None,
80
+ traceback: TracebackType | None,
81
+ ) -> None:
82
+ await self.aclose()
83
+
84
+ async def aclose(self) -> None:
85
+ if self._owns_http:
86
+ await self.http.aclose()
87
+
88
+ async def start_login(
89
+ self, *, prompt: Prompt | None = None, login_hint: str | None = None
90
+ ) -> LoginRequest:
91
+ provider = await self._provider.get()
92
+ handshake = LoginHandshake(
93
+ state=secrets.token_urlsafe(_RANDOM_BYTES),
94
+ nonce=secrets.token_urlsafe(_RANDOM_BYTES),
95
+ code_verifier=secrets.token_urlsafe(_PKCE_VERIFIER_BYTES),
96
+ )
97
+ parameters: list[tuple[str, str]] = [
98
+ ("response_type", "code"),
99
+ ("client_id", self.config.client_id),
100
+ ("redirect_uri", self.config.redirect_uri),
101
+ ("scope", " ".join(self.config.scopes)),
102
+ ("state", handshake.state),
103
+ ("nonce", handshake.nonce),
104
+ ("code_challenge", _s256_challenge(handshake.code_verifier)),
105
+ ("code_challenge_method", "S256"),
106
+ ]
107
+ if prompt:
108
+ parameters.append(("prompt", prompt))
109
+ if login_hint:
110
+ parameters.append(("login_hint", login_hint))
111
+ parameters.extend(("resource", resource) for resource in self.config.resources)
112
+ url = _with_query(provider.metadata.authorization_endpoint, parameters)
113
+ return LoginRequest(url=url, handshake=handshake)
114
+
115
+ async def handle_callback(self, callback_url: str, handshake: LoginHandshake) -> LoginResult:
116
+ """Completes the sign-in from the callback URL.
117
+
118
+ `resource` is deliberately not sent on the code exchange: the token is then
119
+ issued for every audience requested at sign-in and each refresh narrows it
120
+ to one.
121
+ """
122
+ provider = await self._provider.get()
123
+ code = _authorization_code(callback_url, handshake, provider)
124
+ tokens = await self._endpoints.token_request(
125
+ provider.metadata.token_endpoint,
126
+ {
127
+ "grant_type": "authorization_code",
128
+ "code": code,
129
+ "redirect_uri": self.config.redirect_uri,
130
+ "code_verifier": handshake.code_verifier,
131
+ },
132
+ )
133
+ if tokens.id_token is None:
134
+ raise ERR.OIDC_002("The token response carries no ID token")
135
+ claims = await self._verify_id_token(provider, tokens.id_token, nonce=handshake.nonce)
136
+ return LoginResult(tokens=tokens, claims=claims, session_id=claims.sid)
137
+
138
+ async def verify_id_token(self, id_token: str) -> IdTokenClaims:
139
+ return await self._verify_id_token(await self._provider.get(), id_token)
140
+
141
+ async def refresh(self, refresh_token: str, *, resource: str | None = None) -> TokenSet:
142
+ """Exchanges a refresh token for fresh tokens.
143
+
144
+ The provider rotates the refresh token on every use: the returned one
145
+ replaces the presented one, and a replayed stale token revokes the whole
146
+ family. Concurrent callers should go through a grant manager. `scope` is
147
+ never sent: the rotated token would be left with the scopes of that
148
+ request only.
149
+ """
150
+ provider = await self._provider.get()
151
+ form = {"grant_type": "refresh_token", "refresh_token": refresh_token}
152
+ if resource:
153
+ form["resource"] = resource
154
+ return await self._endpoints.token_request(provider.metadata.token_endpoint, form)
155
+
156
+ async def logout_url(
157
+ self, *, id_token_hint: str | None = None, state: str | None = None
158
+ ) -> str:
159
+ """URL ending the provider session (RP-initiated logout)."""
160
+ endpoint = (await self._provider.get()).metadata.end_session_endpoint
161
+ if not endpoint:
162
+ raise ERR.OIDC_004("The provider advertises no end_session_endpoint")
163
+ parameters: list[tuple[str, str]] = [("client_id", self.config.client_id)]
164
+ if self.config.post_logout_redirect_uri:
165
+ parameters.append(("post_logout_redirect_uri", self.config.post_logout_redirect_uri))
166
+ if id_token_hint:
167
+ parameters.append(("id_token_hint", id_token_hint))
168
+ if state:
169
+ parameters.append(("state", state))
170
+ return _with_query(endpoint, parameters)
171
+
172
+ async def revoke(self, token: str, hint: TokenTypeHint | None = None) -> None:
173
+ endpoint = (await self._provider.get()).metadata.revocation_endpoint
174
+ if not endpoint:
175
+ raise ERR.OIDC_004("The provider advertises no revocation_endpoint")
176
+ response = await self._endpoints.post(endpoint, _token_form(token, hint))
177
+ if not response.is_success:
178
+ raise ERR.OIDC_003(
179
+ f"Revocation endpoint answered HTTP {response.status_code}",
180
+ details={"status": response.status_code},
181
+ )
182
+
183
+ async def introspect(
184
+ self, token: str, hint: TokenTypeHint | None = None
185
+ ) -> IntrospectionResult:
186
+ endpoint = (await self._provider.get()).metadata.introspection_endpoint
187
+ if not endpoint:
188
+ raise ERR.OIDC_004("The provider advertises no introspection_endpoint")
189
+ response = await self._endpoints.post(endpoint, _token_form(token, hint))
190
+ if not response.is_success:
191
+ raise ERR.OIDC_003(
192
+ f"Introspection endpoint answered HTTP {response.status_code}",
193
+ details={"status": response.status_code},
194
+ )
195
+ try:
196
+ return IntrospectionResult.model_validate(response.json())
197
+ except (ValueError, ValidationError) as err:
198
+ raise ERR.OIDC_003(
199
+ "The introspection response has an unexpected shape", cause=err
200
+ ) from err
201
+
202
+ async def verify_jwt(
203
+ self, token: str, *, audience: str, typ: str | None = None
204
+ ) -> dict[str, Any]:
205
+ """Verifies the signature, issuer, audience and expiry of a JWT signed by the provider.
206
+
207
+ Low-level: prefer the bearer and logout token verifiers. Returns the claims.
208
+ """
209
+ provider = await self._provider.get()
210
+ return (await provider.jwt.verify(token, audience=audience, typ=typ)).claims
211
+
212
+ async def client_credentials(
213
+ self, *, scope: str | None = None, resource: str | None = None
214
+ ) -> TokenSet:
215
+ """`client_credentials` grant for the application itself.
216
+
217
+ No user is bound to the token. Needs a confidential client.
218
+ """
219
+ if self.config.client_secret is None:
220
+ raise ERR.CFG_001("client_credentials requires a client_secret")
221
+ provider = await self._provider.get()
222
+ form = {"grant_type": "client_credentials"}
223
+ if scope:
224
+ form["scope"] = scope
225
+ if resource:
226
+ form["resource"] = resource
227
+ return await self._endpoints.token_request(provider.metadata.token_endpoint, form)
228
+
229
+ async def _verify_id_token(
230
+ self, provider: Provider, id_token: str, *, nonce: str | None = None
231
+ ) -> IdTokenClaims:
232
+ verified = await provider.jwt.verify(id_token, audience=self.config.client_id)
233
+ check_id_token_binding(verified.claims, client_id=self.config.client_id, nonce=nonce)
234
+ try:
235
+ return IdTokenClaims.model_validate(verified.claims)
236
+ except ValidationError as err:
237
+ raise ERR.TOK_001("The ID token claims have an unexpected shape", cause=err) from err
238
+
239
+
240
+ def _with_query(url: str, parameters: Sequence[tuple[str, str]]) -> str:
241
+ """`url` with `parameters` appended to its own query, repeated names kept."""
242
+ parts = urlsplit(url)
243
+ query = "&".join(part for part in (parts.query, urlencode(parameters)) if part)
244
+ return urlunsplit(parts._replace(query=query))
245
+
246
+
247
+ def _token_form(token: str, hint: TokenTypeHint | None) -> dict[str, str]:
248
+ return {"token": token, "token_type_hint": hint} if hint else {"token": token}
249
+
250
+
251
+ def _authorization_code(callback_url: str, handshake: LoginHandshake, provider: Provider) -> str:
252
+ """Checks the authorization response (RFC 6749 §4.1.2, RFC 9207) and returns its code."""
253
+ query = parse_qs(urlsplit(callback_url).query, keep_blank_values=True)
254
+ if any(len(values) != 1 for values in query.values()):
255
+ raise ERR.OIDC_002(details={"reason": "repeated_parameter"})
256
+ params = {name: values[0] for name, values in query.items()}
257
+
258
+ error = params.get("error")
259
+ if error is not None:
260
+ raise ERR.OIDC_002(f"Authorization failed: {error}", details={"oauthError": error})
261
+
262
+ issuer = params.get("iss")
263
+ if issuer is None and provider.metadata.authorization_response_iss_parameter_supported:
264
+ raise ERR.OIDC_002(details={"reason": "missing_iss"})
265
+ if issuer is not None and issuer != provider.metadata.issuer:
266
+ raise ERR.OIDC_002(details={"reason": "iss_mismatch"})
267
+
268
+ state = params.get("state")
269
+ if state is None or not hmac.compare_digest(state.encode(), handshake.state.encode()):
270
+ raise ERR.OIDC_002(details={"reason": "state_mismatch"})
271
+ code = params.get("code")
272
+ if not code:
273
+ raise ERR.OIDC_002(details={"reason": "missing_code"})
274
+ return code