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 +127 -0
- auth_ai/ai.py +101 -0
- auth_ai/claims.py +88 -0
- auth_ai/client.py +274 -0
- auth_ai/config.py +142 -0
- auth_ai/errors.py +162 -0
- auth_ai/gateway.py +195 -0
- auth_ai/gateway_errors.py +74 -0
- auth_ai/grants.py +212 -0
- auth_ai/http.py +129 -0
- auth_ai/id_token.py +38 -0
- auth_ai/introspection.py +19 -0
- auth_ai/jwt.py +130 -0
- auth_ai/messages.py +86 -0
- auth_ai/provider.py +114 -0
- auth_ai/py.typed +0 -0
- auth_ai/server/__init__.py +59 -0
- auth_ai/server/backchannel_logout.py +172 -0
- auth_ai/server/bearer.py +87 -0
- auth_ai/server/billing.py +110 -0
- auth_ai/server/consumption.py +135 -0
- auth_ai/server/machine_api.py +43 -0
- auth_ai/server/machine_tokens.py +65 -0
- auth_ai/server/protected_resource.py +70 -0
- auth_ai/session_user.py +46 -0
- auth_ai/starlette.py +121 -0
- auth_ai/token_endpoint.py +122 -0
- auth_ai/types.py +59 -0
- auth_ai/wallet.py +221 -0
- auth_ai/web/__init__.py +18 -0
- auth_ai/web/ai_routes.py +56 -0
- auth_ai/web/cookies.py +56 -0
- auth_ai/web/handlers.py +419 -0
- auth_ai/web/http.py +91 -0
- auth_ai/web/seal.py +59 -0
- auth_ai/web/sessions.py +94 -0
- auth_ai-0.1.0.dist-info/METADATA +345 -0
- auth_ai-0.1.0.dist-info/RECORD +40 -0
- auth_ai-0.1.0.dist-info/WHEEL +4 -0
- auth_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|