blade-auth-client 0.2.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.
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import import_module
4
+ from typing import Any
5
+
6
+ __version__ = "0.2.0"
7
+
8
+ __all__ = [
9
+ "AuthConfig",
10
+ "AuthError",
11
+ "AuthMiddleware",
12
+ "CasdoorClaims",
13
+ "CasdoorError",
14
+ "ExpiredTokenError",
15
+ "InvalidAudienceError",
16
+ "InvalidTokenError",
17
+ "JwksCache",
18
+ "JwksUnavailableError",
19
+ "LazyProvisioner",
20
+ "OidcClient",
21
+ "SqlAlchemyProvisioner",
22
+ "TokenVerifier",
23
+ "UserProvisionError",
24
+ "authenticate_request",
25
+ "build_callback_url",
26
+ "clear_session_cookie",
27
+ "create_auth_router",
28
+ "make_auth_middleware",
29
+ "make_current_user_dep",
30
+ "make_require_auth_dep",
31
+ "read_session_token",
32
+ "socketio_auth",
33
+ "verify_token",
34
+ "write_session_cookie",
35
+ ]
36
+
37
+ _EXPORTS = {
38
+ "AuthConfig": ("blade_auth_client.config", "AuthConfig"),
39
+ "AuthError": ("blade_auth_client.errors", "AuthError"),
40
+ "AuthMiddleware": ("blade_auth_client.socketio.middleware", "AuthMiddleware"),
41
+ "CasdoorClaims": ("blade_auth_client.users.provisioner", "CasdoorClaims"),
42
+ "CasdoorError": ("blade_auth_client.errors", "CasdoorError"),
43
+ "ExpiredTokenError": ("blade_auth_client.errors", "ExpiredTokenError"),
44
+ "InvalidAudienceError": ("blade_auth_client.errors", "InvalidAudienceError"),
45
+ "InvalidTokenError": ("blade_auth_client.errors", "InvalidTokenError"),
46
+ "JwksCache": ("blade_auth_client.verifier.jwks_cache", "JwksCache"),
47
+ "JwksUnavailableError": ("blade_auth_client.errors", "JwksUnavailableError"),
48
+ "LazyProvisioner": ("blade_auth_client.users.provisioner", "LazyProvisioner"),
49
+ "OidcClient": ("blade_auth_client.casdoor.client", "OidcClient"),
50
+ "SqlAlchemyProvisioner": ("blade_auth_client.users.sqlalchemy_provisioner", "SqlAlchemyProvisioner"),
51
+ "TokenVerifier": ("blade_auth_client.verifier.token_verifier", "TokenVerifier"),
52
+ "UserProvisionError": ("blade_auth_client.errors", "UserProvisionError"),
53
+ "authenticate_request": ("blade_auth_client.fastapi.deps", "authenticate_request"),
54
+ "build_callback_url": ("blade_auth_client.fastapi.urls", "build_callback_url"),
55
+ "clear_session_cookie": ("blade_auth_client.cookie.policy", "clear_session_cookie"),
56
+ "create_auth_router": ("blade_auth_client.fastapi.router", "create_auth_router"),
57
+ "make_auth_middleware": ("blade_auth_client.socketio.middleware", "make_auth_middleware"),
58
+ "make_current_user_dep": ("blade_auth_client.fastapi.deps", "make_current_user_dep"),
59
+ "make_require_auth_dep": ("blade_auth_client.fastapi.deps", "make_require_auth_dep"),
60
+ "read_session_token": ("blade_auth_client.cookie.policy", "read_session_token"),
61
+ "socketio_auth": ("blade_auth_client.socketio.middleware", "socketio_auth"),
62
+ "verify_token": ("blade_auth_client.verifier.token_verifier", "verify_token"),
63
+ "write_session_cookie": ("blade_auth_client.cookie.policy", "write_session_cookie"),
64
+ }
65
+
66
+
67
+ def __getattr__(name: str) -> Any:
68
+ if name not in _EXPORTS:
69
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
70
+
71
+ module_name, attribute_name = _EXPORTS[name]
72
+ module = import_module(module_name)
73
+ return getattr(module, attribute_name)
@@ -0,0 +1,3 @@
1
+ from .client import OidcClient
2
+
3
+ __all__ = ["OidcClient"]
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from blade_auth_client.config import AuthConfig
8
+ from blade_auth_client.errors import CasdoorError
9
+
10
+ TOKEN_PATH = "/api/login/oauth/access_token"
11
+
12
+
13
+ class OidcClient:
14
+ def __init__(
15
+ self,
16
+ config: AuthConfig,
17
+ http_client: httpx.AsyncClient | None = None,
18
+ ) -> None:
19
+ self._config = config
20
+ self._http_client = http_client or httpx.AsyncClient(timeout=10.0)
21
+
22
+ async def exchange_code(
23
+ self,
24
+ code: str,
25
+ redirect_uri: str,
26
+ ) -> str:
27
+ response = await self._http_client.post(
28
+ f"{self._config.internal_casdoor_endpoint.rstrip('/')}{TOKEN_PATH}",
29
+ data={
30
+ "grant_type": "authorization_code",
31
+ "client_id": self._config.casdoor_client_id,
32
+ "client_secret": self._config.casdoor_client_secret,
33
+ "code": code,
34
+ "redirect_uri": redirect_uri,
35
+ },
36
+ )
37
+ payload = self._parse_casdoor_response(response)
38
+ access_token = payload.get("access_token")
39
+ if not isinstance(access_token, str) or not access_token:
40
+ raise CasdoorError("Casdoor token response missing access_token")
41
+ return access_token
42
+
43
+ def _parse_casdoor_response(self, response: httpx.Response) -> dict[str, Any]:
44
+ try:
45
+ payload = response.json()
46
+ except ValueError as exc:
47
+ raise CasdoorError("Casdoor returned a non-JSON response") from exc
48
+
49
+ try:
50
+ response.raise_for_status()
51
+ except httpx.HTTPStatusError as exc:
52
+ raise CasdoorError(self._extract_error_message(payload)) from exc
53
+
54
+ if not isinstance(payload, dict):
55
+ raise CasdoorError("Casdoor returned an unexpected response payload")
56
+
57
+ status = payload.get("status")
58
+ error = payload.get("error")
59
+ if status == "error" or error:
60
+ raise CasdoorError(self._extract_error_message(payload))
61
+
62
+ data = payload.get("data")
63
+ if isinstance(data, dict):
64
+ merged = dict(data)
65
+ for key, value in payload.items():
66
+ if key != "data" and key not in merged:
67
+ merged[key] = value
68
+ return merged
69
+
70
+ return payload
71
+
72
+ @staticmethod
73
+ def _extract_error_message(payload: Any) -> str:
74
+ if not isinstance(payload, dict):
75
+ return "Casdoor returned an error response"
76
+ for key in ("error_description", "msg", "message", "error"):
77
+ value = payload.get(key)
78
+ if isinstance(value, str) and value:
79
+ return value
80
+ return "Casdoor returned an error response"
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import Field
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class AuthConfig(BaseSettings):
10
+ model_config = SettingsConfigDict(env_prefix="", extra="ignore")
11
+
12
+ casdoor_endpoint: str | None = Field(default=None)
13
+ casdoor_client_id: str = Field(...)
14
+ casdoor_client_secret: str = Field(...)
15
+ internal_casdoor_endpoint: str = Field(default="http://127.0.0.1:19000")
16
+ public_casdoor_port: int = Field(default=19000)
17
+ cookie_name: str = Field(default="blade_token")
18
+ cookie_secure: bool = Field(default=False)
19
+ cookie_same_site: Literal["lax", "strict", "none"] = Field(default="lax")
20
+ jwks_cache_ttl_seconds: int = Field(default=300)
21
+ allowed_redirect_origins: list[str] = Field(default_factory=list)
22
+ callback_base_url: str | None = Field(default=None)
@@ -0,0 +1,3 @@
1
+ from .policy import clear_session_cookie, read_session_token, write_session_cookie
2
+
3
+ __all__ = ["clear_session_cookie", "read_session_token", "write_session_cookie"]
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from blade_auth_client.config import AuthConfig
6
+
7
+ DEFAULT_COOKIE_NAME = "blade_token"
8
+ COOKIE_PATH = "/"
9
+ COOKIE_HTTP_ONLY = True
10
+
11
+
12
+ def write_session_cookie(response: Any, token: str, config: AuthConfig) -> None:
13
+ response.set_cookie(
14
+ key=config.cookie_name,
15
+ value=token,
16
+ httponly=COOKIE_HTTP_ONLY,
17
+ path=COOKIE_PATH,
18
+ samesite=config.cookie_same_site,
19
+ secure=config.cookie_secure,
20
+ )
21
+
22
+
23
+ def clear_session_cookie(response: Any, config: AuthConfig) -> None:
24
+ response.delete_cookie(
25
+ key=config.cookie_name,
26
+ httponly=COOKIE_HTTP_ONLY,
27
+ path=COOKIE_PATH,
28
+ samesite=config.cookie_same_site,
29
+ secure=config.cookie_secure,
30
+ )
31
+
32
+
33
+ def read_session_token(request: Any, config: AuthConfig | None = None) -> str | None:
34
+ cookie_name = config.cookie_name if config is not None else DEFAULT_COOKIE_NAME
35
+ cookies = getattr(request, "cookies", None) or {}
36
+ return cookies.get(cookie_name)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class AuthError(Exception):
5
+ """Base exception for blade-auth-client."""
6
+
7
+
8
+ class InvalidTokenError(AuthError):
9
+ """Raised when a token cannot be trusted."""
10
+
11
+
12
+ class InvalidAudienceError(InvalidTokenError):
13
+ """Retained for backward compatibility; no longer raised."""
14
+
15
+
16
+ class ExpiredTokenError(InvalidTokenError):
17
+ """Raised when a token is expired."""
18
+
19
+
20
+ class JwksUnavailableError(AuthError):
21
+ """Raised when JWKS cannot be fetched and no cache is available."""
22
+
23
+
24
+ class CasdoorError(AuthError):
25
+ """Raised when Casdoor returns an error response."""
26
+
27
+
28
+ class UserProvisionError(AuthError):
29
+ """Raised when local user provisioning fails."""
@@ -0,0 +1,23 @@
1
+ from .deps import authenticate_request, make_current_user_dep, make_require_auth_dep
2
+ from .router import (
3
+ CALLBACK_PATH,
4
+ LOGIN_PATH,
5
+ LOGOUT_PATH,
6
+ ME_PATH,
7
+ PROVIDERS_PATH,
8
+ create_auth_router,
9
+ )
10
+ from .urls import build_callback_url
11
+
12
+ __all__ = [
13
+ "CALLBACK_PATH",
14
+ "LOGIN_PATH",
15
+ "LOGOUT_PATH",
16
+ "ME_PATH",
17
+ "PROVIDERS_PATH",
18
+ "authenticate_request",
19
+ "build_callback_url",
20
+ "create_auth_router",
21
+ "make_current_user_dep",
22
+ "make_require_auth_dep",
23
+ ]
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import Depends, HTTPException, Request, status
6
+ from starlette.responses import Response as StarletteResponse
7
+
8
+ from blade_auth_client.cookie.policy import clear_session_cookie, read_session_token
9
+ from blade_auth_client.config import AuthConfig
10
+ from blade_auth_client.errors import InvalidTokenError, JwksUnavailableError, UserProvisionError
11
+ from blade_auth_client.users.provisioner import CasdoorClaims
12
+ from blade_auth_client.users.provisioner import LazyProvisioner
13
+ from blade_auth_client.verifier.token_verifier import TokenVerifier
14
+
15
+
16
+ async def authenticate_request(
17
+ request: Request,
18
+ config: AuthConfig,
19
+ verifier: TokenVerifier,
20
+ provisioner: LazyProvisioner[Any],
21
+ *,
22
+ allow_query_token: bool = False,
23
+ ) -> tuple[Any, CasdoorClaims, str] | None:
24
+ token, token_source = _extract_token(
25
+ request,
26
+ config,
27
+ allow_query_token=allow_query_token,
28
+ )
29
+ if token is None:
30
+ _set_request_state(request, token=None, claims=None, user=None)
31
+ return None
32
+
33
+ try:
34
+ payload = await verifier.verify(token)
35
+ except InvalidTokenError as exc:
36
+ headers = None
37
+ if token_source == "cookie":
38
+ clear_cookie_response = StarletteResponse()
39
+ clear_session_cookie(clear_cookie_response, config)
40
+ set_cookie_header = clear_cookie_response.headers.get("set-cookie")
41
+ if set_cookie_header is not None:
42
+ headers = {"set-cookie": set_cookie_header}
43
+ _set_request_state(request, token=None, claims=None, user=None)
44
+ raise HTTPException(
45
+ status_code=status.HTTP_401_UNAUTHORIZED,
46
+ detail="Unauthorized",
47
+ headers=headers,
48
+ ) from exc
49
+ except JwksUnavailableError as exc:
50
+ raise HTTPException(
51
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
52
+ detail="JWKS unavailable",
53
+ ) from exc
54
+
55
+ try:
56
+ claims = _build_casdoor_claims(payload)
57
+ user = await provisioner.ensure_user(claims)
58
+ except UserProvisionError as exc:
59
+ raise HTTPException(
60
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
61
+ detail="Failed to provision user",
62
+ ) from exc
63
+
64
+ _set_request_state(request, token=token, claims=claims, user=user)
65
+ return user, claims, token
66
+
67
+
68
+ def make_current_user_dep(
69
+ config: AuthConfig,
70
+ *,
71
+ verifier: TokenVerifier,
72
+ provisioner: LazyProvisioner[Any],
73
+ ) -> Any:
74
+ async def _current_user(request: Request) -> Any | None:
75
+ authenticated = await authenticate_request(
76
+ request,
77
+ config,
78
+ verifier,
79
+ provisioner,
80
+ )
81
+ if authenticated is None:
82
+ return None
83
+ user, _, _ = authenticated
84
+ return user
85
+
86
+ return Depends(_current_user)
87
+
88
+
89
+ def make_require_auth_dep(
90
+ config: AuthConfig,
91
+ *,
92
+ verifier: TokenVerifier,
93
+ provisioner: LazyProvisioner[Any],
94
+ ) -> Any:
95
+ current_user_dep = make_current_user_dep(
96
+ config,
97
+ verifier=verifier,
98
+ provisioner=provisioner,
99
+ )
100
+
101
+ async def _require_auth(current_user: Any = current_user_dep) -> Any:
102
+ if current_user is None:
103
+ raise HTTPException(
104
+ status_code=status.HTTP_401_UNAUTHORIZED,
105
+ detail="Unauthorized",
106
+ )
107
+ return current_user
108
+
109
+ return Depends(_require_auth)
110
+
111
+
112
+ def _extract_token(
113
+ request: Request,
114
+ config: AuthConfig,
115
+ *,
116
+ allow_query_token: bool = False,
117
+ ) -> tuple[str | None, str | None]:
118
+ authorization = request.headers.get("authorization", "")
119
+ scheme, _, credentials = authorization.partition(" ")
120
+ if scheme.lower() == "bearer" and credentials:
121
+ return credentials, "bearer"
122
+
123
+ if allow_query_token:
124
+ query_token = request.query_params.get("token", "").strip()
125
+ if query_token:
126
+ return query_token, "query"
127
+
128
+ cookie_token = read_session_token(request, config)
129
+ if cookie_token:
130
+ return cookie_token, "cookie"
131
+
132
+ return None, None
133
+
134
+
135
+ def _build_casdoor_claims(claims: dict[str, Any]) -> CasdoorClaims:
136
+ sub = claims.get("sub")
137
+ if not isinstance(sub, str) or not sub:
138
+ raise HTTPException(
139
+ status_code=status.HTTP_401_UNAUTHORIZED,
140
+ detail="Unauthorized",
141
+ )
142
+
143
+ return CasdoorClaims(
144
+ sub=sub,
145
+ email=_optional_string(claims.get("email")),
146
+ preferred_username=_optional_string(claims.get("preferred_username")),
147
+ name=_optional_string(claims.get("name") or claims.get("displayName")),
148
+ picture=_optional_string(claims.get("picture") or claims.get("avatar")),
149
+ )
150
+
151
+
152
+ def _set_request_state(
153
+ request: Request,
154
+ *,
155
+ token: str | None,
156
+ claims: CasdoorClaims | None,
157
+ user: Any | None,
158
+ ) -> None:
159
+ request.state.blade_auth_token = token
160
+ request.state.blade_auth_claims = claims
161
+ request.state.blade_auth_user = user
162
+
163
+
164
+ def _optional_string(value: Any) -> str | None:
165
+ if isinstance(value, str) and value:
166
+ return value
167
+ return None
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from collections.abc import Callable
6
+ from secrets import token_urlsafe
7
+ from typing import Any
8
+ from urllib.parse import urlencode, urlparse
9
+
10
+ from fastapi import APIRouter, HTTPException, Request, status
11
+ from fastapi.responses import JSONResponse, RedirectResponse
12
+
13
+ from blade_auth_client.casdoor.client import OidcClient
14
+ from blade_auth_client.cookie.policy import clear_session_cookie, write_session_cookie
15
+ from blade_auth_client.config import AuthConfig
16
+ from blade_auth_client.errors import CasdoorError, InvalidTokenError, JwksUnavailableError, UserProvisionError
17
+ from blade_auth_client.fastapi.deps import _build_casdoor_claims, make_require_auth_dep
18
+ from blade_auth_client.fastapi.urls import build_callback_url, build_public_casdoor_base, hostname_from_host
19
+ from blade_auth_client.users.provisioner import CasdoorClaims, LazyProvisioner
20
+ from blade_auth_client.verifier.token_verifier import TokenVerifier
21
+
22
+ LOGGER = logging.getLogger(__name__)
23
+ AUTHORIZE_URL = "/api/auth/login"
24
+ STATE_TTL_SECONDS = 300
25
+ AUTHORIZE_PATH = "/login/oauth/authorize"
26
+
27
+ # 路由 path 常量(相对于消费方挂载的 prefix;single source of truth)
28
+ LOGIN_PATH = "/login"
29
+ CALLBACK_PATH = "/oauth/casdoor/callback"
30
+ LOGOUT_PATH = "/logout"
31
+ ME_PATH = "/me"
32
+ PROVIDERS_PATH = "/providers"
33
+
34
+ MeSerializer = Callable[[Any, CasdoorClaims | None, Request], dict[str, Any]]
35
+
36
+
37
+ def create_auth_router(
38
+ config: AuthConfig,
39
+ *,
40
+ verifier: TokenVerifier,
41
+ provisioner: LazyProvisioner[Any],
42
+ oidc_client: OidcClient,
43
+ me_serializer: MeSerializer | None = None,
44
+ ) -> Any:
45
+ router = APIRouter()
46
+ require_auth = make_require_auth_dep(
47
+ config,
48
+ verifier=verifier,
49
+ provisioner=provisioner,
50
+ )
51
+ serialize_me = me_serializer or _default_me_serializer
52
+ state_store: dict[str, tuple[str, float]] = {}
53
+
54
+ async def _warm_jwks() -> None:
55
+ if not hasattr(verifier, "prewarm"):
56
+ return
57
+ try:
58
+ await verifier.prewarm()
59
+ except JwksUnavailableError:
60
+ LOGGER.warning("Initial JWKS prewarm failed", exc_info=True)
61
+
62
+ router.add_event_handler("startup", _warm_jwks)
63
+
64
+ def _prune_states() -> None:
65
+ now = time.monotonic()
66
+ expired_states = [key for key, (_, expires_at) in state_store.items() if expires_at <= now]
67
+ for key in expired_states:
68
+ state_store.pop(key, None)
69
+
70
+ def _store_state(next_url: str) -> str:
71
+ _prune_states()
72
+ state = token_urlsafe(32)
73
+ state_store[state] = (next_url, time.monotonic() + STATE_TTL_SECONDS)
74
+ return state
75
+
76
+ def _consume_state(state: str) -> str | None:
77
+ _prune_states()
78
+ entry = state_store.pop(state, None)
79
+ if entry is None:
80
+ return None
81
+
82
+ next_url, expires_at = entry
83
+ if expires_at <= time.monotonic():
84
+ return None
85
+ return next_url
86
+
87
+ @router.get(LOGIN_PATH)
88
+ async def login(request: Request, next: str | None = None) -> RedirectResponse:
89
+ if next is None:
90
+ if not config.allowed_redirect_origins:
91
+ raise HTTPException(
92
+ status_code=status.HTTP_400_BAD_REQUEST,
93
+ detail="next required: no default redirect origin configured",
94
+ )
95
+ next = config.allowed_redirect_origins[0]
96
+ _validate_next_url(next, config, request=request)
97
+ state = _store_state(next)
98
+ callback_url = _resolve_callback_url(request, config, LOGIN_PATH)
99
+ authorize_url = _build_authorize_url(
100
+ request=request,
101
+ config=config,
102
+ state=state,
103
+ redirect_uri=callback_url,
104
+ )
105
+ return RedirectResponse(authorize_url, status_code=status.HTTP_302_FOUND)
106
+
107
+ @router.get(CALLBACK_PATH)
108
+ async def callback(request: Request, code: str, state: str) -> RedirectResponse:
109
+ next_url = _consume_state(state)
110
+ if next_url is None:
111
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid state")
112
+
113
+ try:
114
+ access_token = await oidc_client.exchange_code(
115
+ code,
116
+ _resolve_callback_url(request, config, CALLBACK_PATH),
117
+ )
118
+ claims = await verifier.verify(access_token)
119
+ await provisioner.ensure_user(_build_casdoor_claims(claims))
120
+ except InvalidTokenError as exc:
121
+ raise HTTPException(
122
+ status_code=status.HTTP_401_UNAUTHORIZED,
123
+ detail="Unauthorized",
124
+ ) from exc
125
+ except JwksUnavailableError as exc:
126
+ raise HTTPException(
127
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
128
+ detail="JWKS unavailable",
129
+ ) from exc
130
+ except (CasdoorError, UserProvisionError) as exc:
131
+ raise HTTPException(
132
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
133
+ detail=str(exc),
134
+ ) from exc
135
+
136
+ response = RedirectResponse(next_url, status_code=status.HTTP_302_FOUND)
137
+ write_session_cookie(response, access_token, config)
138
+ return response
139
+
140
+ @router.get(LOGOUT_PATH)
141
+ async def logout_redirect(request: Request) -> RedirectResponse:
142
+ response = RedirectResponse(_build_logout_url(request, config), status_code=status.HTTP_302_FOUND)
143
+ clear_session_cookie(response, config)
144
+ return response
145
+
146
+ @router.post(LOGOUT_PATH)
147
+ async def logout_json(request: Request) -> JSONResponse:
148
+ response = JSONResponse({"logout_url": _build_logout_url(request, config)})
149
+ clear_session_cookie(response, config)
150
+ return response
151
+
152
+ @router.get(PROVIDERS_PATH)
153
+ async def providers(request: Request) -> dict[str, Any]:
154
+ # 根据实际挂载的 router prefix 拼 login URL,避免硬编码 /api/auth/login
155
+ # 在不同前缀下返回错误地址(blade-os 挂 /api/v1/auth、消费方可能不用前缀)
156
+ path = request.url.path
157
+ assert path.endswith(PROVIDERS_PATH)
158
+ authorize_url = path[: -len(PROVIDERS_PATH)] + LOGIN_PATH
159
+ return {
160
+ "providers": [{"name": "casdoor", "authorize_url": authorize_url}],
161
+ "password_enabled": False,
162
+ }
163
+
164
+ @router.get(ME_PATH)
165
+ async def me(request: Request, user: Any = require_auth) -> dict[str, Any]:
166
+ claims = getattr(request.state, "blade_auth_claims", None)
167
+ return serialize_me(user, claims, request)
168
+
169
+ return router
170
+
171
+
172
+ def _build_authorize_url(
173
+ *,
174
+ request: Request,
175
+ config: AuthConfig,
176
+ state: str,
177
+ redirect_uri: str,
178
+ ) -> str:
179
+ public_base = build_public_casdoor_base(request, public_port=config.public_casdoor_port)
180
+ return f"{public_base}{AUTHORIZE_PATH}?{urlencode(_authorize_query(config, state, redirect_uri))}"
181
+
182
+
183
+ def _authorize_query(config: AuthConfig, state: str, redirect_uri: str) -> dict[str, str]:
184
+ return {
185
+ "client_id": config.casdoor_client_id,
186
+ "redirect_uri": redirect_uri,
187
+ "response_type": "code",
188
+ "scope": "openid profile email",
189
+ "state": state,
190
+ }
191
+
192
+
193
+ def _resolve_callback_url(request: Request, config: AuthConfig, current_route: str) -> str:
194
+ if request.headers.get("host") or request.url.hostname:
195
+ return build_callback_url(request, _route_prefix(request, current_route))
196
+ if config.callback_base_url:
197
+ return config.callback_base_url.rstrip("/")
198
+ return build_callback_url(request, "")
199
+
200
+
201
+ def _build_logout_url(request: Request, config: AuthConfig) -> str:
202
+ if request.headers.get("host") or request.url.hostname:
203
+ return f"{build_public_casdoor_base(request, public_port=config.public_casdoor_port)}/logout"
204
+ if config.casdoor_endpoint:
205
+ return f"{config.casdoor_endpoint.rstrip('/')}/logout"
206
+ return f"{config.internal_casdoor_endpoint.rstrip('/')}/logout"
207
+
208
+
209
+ def _route_prefix(request: Request, route_path: str) -> str:
210
+ path = request.url.path.rstrip("/")
211
+ normalized_route = route_path.rstrip("/")
212
+ if path.endswith(normalized_route):
213
+ return path[: -len(normalized_route)]
214
+ return ""
215
+
216
+
217
+ def _validate_next_url(
218
+ next_url: str,
219
+ config: AuthConfig,
220
+ *,
221
+ request: Request | None = None,
222
+ ) -> None:
223
+ parsed = urlparse(next_url)
224
+ if not parsed.scheme or not parsed.netloc:
225
+ raise HTTPException(
226
+ status_code=status.HTTP_400_BAD_REQUEST,
227
+ detail="next must be an absolute URL",
228
+ )
229
+
230
+ origin = f"{parsed.scheme}://{parsed.netloc}"
231
+ if origin in set(config.allowed_redirect_origins):
232
+ return
233
+
234
+ # 仅在运维没有显式配置 allowlist 时才落入 request-host fallback(动态 IP 场景);
235
+ # 一旦配了 allowlist,严格比对完整 origin,不能用 same-host 绕开端口/协议限制。
236
+ if not config.allowed_redirect_origins and request is not None:
237
+ request_host = request.headers.get("host")
238
+ request_hostname = hostname_from_host(request_host) if request_host else request.url.hostname
239
+ if parsed.hostname == request_hostname:
240
+ return
241
+
242
+ raise HTTPException(
243
+ status_code=status.HTTP_400_BAD_REQUEST,
244
+ detail="next origin is not allowed",
245
+ )
246
+
247
+
248
+ def _default_me_serializer(
249
+ user: Any,
250
+ claims: CasdoorClaims | None,
251
+ request: Request,
252
+ ) -> dict[str, Any]:
253
+ return {
254
+ "id": _read_user_value(user, "id"),
255
+ "username": _coalesce(
256
+ _read_user_value(user, "username"),
257
+ _read_user_value(user, "name"),
258
+ getattr(claims, "preferred_username", None),
259
+ getattr(claims, "name", None),
260
+ getattr(claims, "email", None),
261
+ getattr(claims, "sub", None),
262
+ ),
263
+ "avatar_url": _coalesce(
264
+ _read_user_value(user, "avatar_url"),
265
+ _read_user_value(user, "avatar"),
266
+ getattr(claims, "picture", None),
267
+ ),
268
+ "token": getattr(request.state, "blade_auth_token", None),
269
+ "auth_type": "casdoor",
270
+ }
271
+
272
+
273
+ def _read_user_value(user: Any, key: str) -> Any:
274
+ if isinstance(user, dict):
275
+ return user.get(key)
276
+ return getattr(user, key, None)
277
+
278
+
279
+ def _coalesce(*values: Any) -> Any:
280
+ for value in values:
281
+ if value is not None:
282
+ return value
283
+ return None
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import Request
4
+
5
+ DEFAULT_PROVIDER = "casdoor"
6
+
7
+
8
+ def build_callback_url(request: Request, prefix: str, provider: str = DEFAULT_PROVIDER) -> str:
9
+ scheme, host = request_origin(request)
10
+ normalized_prefix = _normalize_prefix(prefix)
11
+ return f"{scheme}://{host}{normalized_prefix}/oauth/{provider}/callback"
12
+
13
+
14
+ def build_public_casdoor_base(request: Request, *, public_port: int) -> str:
15
+ scheme, host = request_origin(request)
16
+ hostname = hostname_from_host(host)
17
+ return f"{scheme}://{_format_netloc(hostname, public_port)}"
18
+
19
+
20
+ def request_origin(request: Request) -> tuple[str, str]:
21
+ scheme = request.url.scheme or "http"
22
+ host = request.headers.get("host") or request.url.netloc or "127.0.0.1"
23
+ return scheme, host
24
+
25
+
26
+ def hostname_from_host(host: str) -> str:
27
+ if host.startswith("["):
28
+ return host.split("]", 1)[0][1:]
29
+ if host.count(":") == 1:
30
+ return host.rsplit(":", 1)[0]
31
+ return host
32
+
33
+
34
+ def _format_netloc(hostname: str, port: int) -> str:
35
+ if ":" in hostname and not hostname.startswith("["):
36
+ return f"[{hostname}]:{port}"
37
+ return f"{hostname}:{port}"
38
+
39
+
40
+ def _normalize_prefix(prefix: str) -> str:
41
+ if not prefix:
42
+ return ""
43
+ if not prefix.startswith("/"):
44
+ prefix = f"/{prefix}"
45
+ return prefix.rstrip("/")
@@ -0,0 +1,3 @@
1
+ from .middleware import AuthMiddleware, make_auth_middleware, socketio_auth
2
+
3
+ __all__ = ["AuthMiddleware", "make_auth_middleware", "socketio_auth"]
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from http.cookies import SimpleCookie
5
+ from typing import Any, Generic, TypeVar
6
+
7
+ from blade_auth_client.errors import InvalidTokenError
8
+ from blade_auth_client.users.provisioner import CasdoorClaims
9
+ from blade_auth_client.users.provisioner import LazyProvisioner
10
+ from blade_auth_client.verifier.token_verifier import TokenVerifier
11
+
12
+ UserT = TypeVar("UserT")
13
+
14
+
15
+ class AuthMiddleware(Generic[UserT]):
16
+ def __init__(
17
+ self,
18
+ verifier: TokenVerifier,
19
+ provisioner: LazyProvisioner[UserT],
20
+ cookie_name: str = "blade_token",
21
+ ) -> None:
22
+ self._verifier = verifier
23
+ self._provisioner = provisioner
24
+ self._cookie_name = cookie_name
25
+
26
+ async def __call__(self, sid: str, environ: dict[str, Any], auth: Any | None) -> UserT | None:
27
+ user, _ = await socketio_auth(
28
+ self._verifier,
29
+ self._provisioner,
30
+ environ,
31
+ auth=auth,
32
+ cookie_name=self._cookie_name,
33
+ )
34
+ return user
35
+
36
+
37
+ async def socketio_auth(
38
+ verifier: TokenVerifier,
39
+ provisioner: LazyProvisioner[UserT],
40
+ environ: Mapping[str, Any],
41
+ *,
42
+ auth: Any | None = None,
43
+ cookie_name: str = "blade_token",
44
+ ) -> tuple[UserT, str] | tuple[None, None]:
45
+ token = _extract_socket_token(auth, environ, cookie_name=cookie_name)
46
+ if token is None:
47
+ return None, None
48
+
49
+ claims = await verifier.verify(token)
50
+ user = await provisioner.ensure_user(_build_casdoor_claims(claims))
51
+ return user, token
52
+
53
+
54
+ def make_auth_middleware(
55
+ verifier: TokenVerifier,
56
+ provisioner: LazyProvisioner[UserT],
57
+ cookie_name: str = "blade_token",
58
+ ) -> AuthMiddleware[UserT]:
59
+ return AuthMiddleware(
60
+ verifier=verifier,
61
+ provisioner=provisioner,
62
+ cookie_name=cookie_name,
63
+ )
64
+
65
+
66
+ def _extract_socket_token(
67
+ auth: Any | None,
68
+ environ: Mapping[str, Any],
69
+ *,
70
+ cookie_name: str,
71
+ ) -> str | None:
72
+ if isinstance(auth, Mapping):
73
+ raw_token = auth.get("token")
74
+ if isinstance(raw_token, str):
75
+ token = raw_token.strip()
76
+ if token:
77
+ return token
78
+
79
+ raw_cookie = environ.get("HTTP_COOKIE")
80
+ if not isinstance(raw_cookie, str) or not raw_cookie.strip():
81
+ return None
82
+
83
+ cookie = SimpleCookie()
84
+ cookie.load(raw_cookie)
85
+ morsel = cookie.get(cookie_name)
86
+ if morsel is None:
87
+ return None
88
+
89
+ token = morsel.value.strip()
90
+ return token or None
91
+
92
+
93
+ def _build_casdoor_claims(claims: Mapping[str, Any]) -> CasdoorClaims:
94
+ sub = claims.get("sub")
95
+ if not isinstance(sub, str) or not sub:
96
+ raise InvalidTokenError("Token subject is invalid")
97
+
98
+ return CasdoorClaims(
99
+ sub=sub,
100
+ email=_optional_string(claims.get("email")),
101
+ preferred_username=_optional_string(claims.get("preferred_username")),
102
+ name=_optional_string(claims.get("name") or claims.get("displayName")),
103
+ picture=_optional_string(claims.get("picture") or claims.get("avatar")),
104
+ )
105
+
106
+
107
+ def _optional_string(value: Any) -> str | None:
108
+ if isinstance(value, str) and value:
109
+ return value
110
+ return None
@@ -0,0 +1,4 @@
1
+ from .provisioner import CasdoorClaims, LazyProvisioner
2
+ from .sqlalchemy_provisioner import SqlAlchemyProvisioner
3
+
4
+ __all__ = ["CasdoorClaims", "LazyProvisioner", "SqlAlchemyProvisioner"]
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol, TypeVar, runtime_checkable
5
+
6
+ UserT = TypeVar("UserT")
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class CasdoorClaims:
11
+ sub: str
12
+ email: str | None = None
13
+ preferred_username: str | None = None
14
+ name: str | None = None
15
+ picture: str | None = None
16
+
17
+
18
+ @runtime_checkable
19
+ class LazyProvisioner(Protocol[UserT]):
20
+ async def ensure_user(self, claims: CasdoorClaims) -> UserT:
21
+ """Return an existing local user or lazily create one."""
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Any
5
+
6
+ from sqlalchemy import inspect as sa_inspect
7
+ from sqlalchemy import select
8
+
9
+ from blade_auth_client.errors import UserProvisionError
10
+
11
+ from .provisioner import CasdoorClaims
12
+
13
+ AfterLoginHook = Callable[[Any, Any, CasdoorClaims], Any | Awaitable[Any]]
14
+
15
+
16
+ class SqlAlchemyProvisioner:
17
+ def __init__(
18
+ self,
19
+ sessionmaker: Callable[[], Any],
20
+ user_model: type[Any],
21
+ after_login: AfterLoginHook | None = None,
22
+ *,
23
+ id_field: str = "id",
24
+ subject_field: str = "casdoor_sub",
25
+ username_field: str = "username",
26
+ email_field: str = "email",
27
+ avatar_url_field: str = "avatar_url",
28
+ ) -> None:
29
+ self._sessionmaker = sessionmaker
30
+ self._user_model = user_model
31
+ self._after_login = after_login
32
+ self._id_field = id_field
33
+ self._subject_field = subject_field
34
+ self._username_field = username_field
35
+ self._email_field = email_field
36
+ self._avatar_url_field = avatar_url_field
37
+ self._validate_model_fields()
38
+
39
+ async def ensure_user(self, claims: CasdoorClaims) -> Any:
40
+ username = _resolve_username(claims)
41
+ try:
42
+ with self._sessionmaker() as session:
43
+ user = session.scalar(
44
+ select(self._user_model).where(
45
+ getattr(self._user_model, self._subject_field) == claims.sub
46
+ )
47
+ )
48
+ created = False
49
+ if user is None:
50
+ user = self._user_model(
51
+ **{
52
+ self._subject_field: claims.sub,
53
+ self._username_field: username,
54
+ self._email_field: claims.email,
55
+ self._avatar_url_field: claims.picture,
56
+ }
57
+ )
58
+ session.add(user)
59
+ session.flush()
60
+ created = True
61
+
62
+ updated = self._sync_user(user, claims, username)
63
+ if self._after_login is not None:
64
+ maybe_awaitable = self._after_login(session, user, claims)
65
+ if isinstance(maybe_awaitable, Awaitable):
66
+ await maybe_awaitable
67
+
68
+ if created or updated:
69
+ session.flush()
70
+ session.commit()
71
+ session.refresh(user)
72
+ return user
73
+ except UserProvisionError:
74
+ raise
75
+ except Exception as exc:
76
+ raise UserProvisionError("Failed to provision user") from exc
77
+
78
+ def _sync_user(self, user: Any, claims: CasdoorClaims, username: str) -> bool:
79
+ changed = False
80
+ if getattr(user, self._username_field, None) != username:
81
+ setattr(user, self._username_field, username)
82
+ changed = True
83
+ if claims.email is not None and getattr(user, self._email_field, None) != claims.email:
84
+ setattr(user, self._email_field, claims.email)
85
+ changed = True
86
+ if claims.picture is not None and getattr(user, self._avatar_url_field, None) != claims.picture:
87
+ setattr(user, self._avatar_url_field, claims.picture)
88
+ changed = True
89
+ return changed
90
+
91
+ def _validate_model_fields(self) -> None:
92
+ mapper = sa_inspect(self._user_model)
93
+ known_fields = set(mapper.attrs.keys())
94
+ missing = [
95
+ field_name
96
+ for field_name in (
97
+ self._id_field,
98
+ self._subject_field,
99
+ self._username_field,
100
+ self._email_field,
101
+ self._avatar_url_field,
102
+ )
103
+ if field_name not in known_fields
104
+ ]
105
+ if missing:
106
+ raise ValueError(
107
+ "user_model is missing required fields: " + ", ".join(sorted(missing))
108
+ )
109
+
110
+
111
+ def _resolve_username(claims: CasdoorClaims) -> str:
112
+ return claims.preferred_username or claims.name or claims.email or claims.sub
@@ -0,0 +1,4 @@
1
+ from .jwks_cache import JwksCache
2
+ from .token_verifier import TokenVerifier, verify_token
3
+
4
+ __all__ = ["JwksCache", "TokenVerifier", "verify_token"]
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ from typing import Any, Callable
7
+
8
+ import httpx
9
+ import jwt
10
+
11
+ from blade_auth_client.config import AuthConfig
12
+ from blade_auth_client.errors import InvalidTokenError, JwksUnavailableError
13
+
14
+ LOGGER = logging.getLogger(__name__)
15
+ JWKS_PATH = "/.well-known/jwks"
16
+
17
+
18
+ class JwksCache:
19
+ def __init__(
20
+ self,
21
+ config: AuthConfig,
22
+ http_client: httpx.AsyncClient | None = None,
23
+ *,
24
+ clock: Callable[[], float] | None = None,
25
+ logger: logging.Logger | None = None,
26
+ ) -> None:
27
+ self._config = config
28
+ self._http_client = http_client or httpx.AsyncClient(timeout=10.0)
29
+ self._clock = clock or time.monotonic
30
+ self._logger = logger or LOGGER
31
+ self._jwks: dict[str, Any] | None = None
32
+ self._expires_at = 0.0
33
+
34
+ async def get_jwks(self) -> dict[str, Any]:
35
+ if self._jwks is not None and self._clock() < self._expires_at:
36
+ return self._jwks
37
+
38
+ return await self.refresh()
39
+
40
+ async def get_signing_key(self, token: str) -> Any:
41
+ try:
42
+ header = jwt.get_unverified_header(token)
43
+ except jwt.PyJWTError as exc:
44
+ raise InvalidTokenError("Token header is invalid") from exc
45
+
46
+ kid = header.get("kid")
47
+ if not isinstance(kid, str) or not kid:
48
+ raise InvalidTokenError("Token header missing kid")
49
+
50
+ jwks = await self.get_jwks()
51
+ key = self._find_key(jwks, kid)
52
+ if key is not None:
53
+ return key
54
+
55
+ # Casdoor 可能在 TTL 窗口内轮换 kid;本地缓存未命中时强制刷新一次再查,避免
56
+ # 在 jwks_cache_ttl_seconds 内登录全部失败
57
+ jwks = await self.refresh()
58
+ key = self._find_key(jwks, kid)
59
+ if key is not None:
60
+ return key
61
+
62
+ raise InvalidTokenError("Unable to find signing key for token")
63
+
64
+ @staticmethod
65
+ def _find_key(jwks: dict[str, Any], kid: str) -> Any | None:
66
+ for key_data in jwks.get("keys", []):
67
+ if key_data.get("kid") == kid:
68
+ return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key_data))
69
+ return None
70
+
71
+ async def refresh(self) -> dict[str, Any]:
72
+ try:
73
+ response = await self._http_client.get(
74
+ f"{self._config.internal_casdoor_endpoint.rstrip('/')}{JWKS_PATH}"
75
+ )
76
+ response.raise_for_status()
77
+ payload = response.json()
78
+ if not isinstance(payload, dict) or not isinstance(payload.get("keys"), list):
79
+ raise ValueError("JWKS payload is missing keys")
80
+ except Exception as exc:
81
+ if self._jwks is not None:
82
+ self._logger.warning("Failed to refresh JWKS, falling back to cached keys", exc_info=exc)
83
+ return self._jwks
84
+ raise JwksUnavailableError("Unable to fetch JWKS") from exc
85
+
86
+ self._jwks = payload
87
+ self._expires_at = self._clock() + self._config.jwks_cache_ttl_seconds
88
+ return payload
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import jwt
6
+ from jwt.exceptions import (
7
+ ExpiredSignatureError,
8
+ InvalidAlgorithmError,
9
+ InvalidTokenError as JwtInvalidTokenError,
10
+ MissingRequiredClaimError,
11
+ PyJWTError,
12
+ )
13
+
14
+ from blade_auth_client.config import AuthConfig
15
+ from blade_auth_client.errors import ExpiredTokenError, InvalidTokenError
16
+ from blade_auth_client.verifier.jwks_cache import JwksCache
17
+
18
+
19
+ class TokenVerifier:
20
+ def __init__(self, config: AuthConfig, jwks_cache: JwksCache | None = None) -> None:
21
+ self._config = config
22
+ self._jwks_cache = jwks_cache or JwksCache(config)
23
+
24
+ async def prewarm(self) -> None:
25
+ await self._jwks_cache.refresh()
26
+
27
+ async def verify(self, token: str) -> dict[str, Any]:
28
+ try:
29
+ header = jwt.get_unverified_header(token)
30
+ if header.get("alg") != "RS256":
31
+ raise InvalidAlgorithmError("Token algorithm is invalid")
32
+ signing_key = await self._jwks_cache.get_signing_key(token)
33
+ return jwt.decode(
34
+ token,
35
+ key=signing_key,
36
+ algorithms=["RS256"],
37
+ options={"require": ["exp"], "verify_aud": False},
38
+ )
39
+ except ExpiredSignatureError as exc:
40
+ raise ExpiredTokenError("Token has expired") from exc
41
+ except MissingRequiredClaimError as exc:
42
+ raise InvalidTokenError("Token claims are invalid") from exc
43
+ except (InvalidAlgorithmError, JwtInvalidTokenError, PyJWTError) as exc:
44
+ raise InvalidTokenError("Token is invalid") from exc
45
+
46
+
47
+ async def verify_token(token: str, *, verifier: TokenVerifier) -> dict[str, Any]:
48
+ return await verifier.verify(token)
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: blade-auth-client
3
+ Version: 0.2.0
4
+ Summary: Shared Casdoor authentication client for Blade ecosystem services.
5
+ Project-URL: Homepage, https://pypi.org/project/blade-auth-client/
6
+ Author-email: yangliu35 <so.2liu@gmail.com>
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: httpx
10
+ Requires-Dist: pydantic-settings
11
+ Requires-Dist: pydantic>=2
12
+ Requires-Dist: pyjwt[crypto]
13
+ Requires-Dist: sqlalchemy
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == 'dev'
16
+ Requires-Dist: pytest-asyncio; extra == 'dev'
17
+ Requires-Dist: respx; extra == 'dev'
18
+ Requires-Dist: ruff; extra == 'dev'
19
+ Provides-Extra: fastapi
20
+ Requires-Dist: fastapi; extra == 'fastapi'
21
+ Requires-Dist: starlette; extra == 'fastapi'
22
+ Provides-Extra: socketio
23
+ Requires-Dist: python-socketio; extra == 'socketio'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # blade-auth-client
27
+
28
+ `blade-auth-client` 是 Blade 生态的共享 Casdoor 认证客户端包。它抽离了统一的 token 校验、FastAPI 装配和 Socket.IO 握手接口,供各个服务复用。
29
+
30
+ ## 安装
31
+
32
+ ```bash
33
+ pip install blade-auth-client[fastapi,socketio]
34
+ ```
35
+
36
+ ## Casdoor 配置要求
37
+
38
+ Casdoor 的 application、scope 和 redirect URI 约束见 [docs/casdoor-setup.md](docs/casdoor-setup.md)。
39
+
40
+ ## 快速上手
41
+
42
+ ```python
43
+ from fastapi import FastAPI
44
+
45
+ from blade_auth_client import (
46
+ AuthConfig,
47
+ OidcClient,
48
+ TokenVerifier,
49
+ create_auth_router,
50
+ make_current_user_dep,
51
+ )
52
+
53
+ app = FastAPI()
54
+ config = AuthConfig()
55
+ oidc_client = OidcClient(config)
56
+ verifier = TokenVerifier(config)
57
+ current_user = make_current_user_dep(config, verifier=verifier, provisioner=...)
58
+
59
+ app.include_router(
60
+ create_auth_router(config, verifier=verifier, provisioner=..., oidc_client=oidc_client)
61
+ )
62
+ ```
63
+
64
+ ## 版本策略
65
+
66
+ `0.0.x` 版本仅用于占位发布和联调验证,不承诺可用性。`0.2.0` 起采用仅校验 `iss` + 签名 + `exp` 的简化策略。
@@ -0,0 +1,22 @@
1
+ blade_auth_client/__init__.py,sha256=JCY1fBnFiEGw_N51eLOPwfAFtpFiFzDPUENZJCYX6P0,3175
2
+ blade_auth_client/config.py,sha256=VuPQvudOiPdopb-cPSxjpqyDgbXdyaR5vOUHKcbgmFE,882
3
+ blade_auth_client/errors.py,sha256=LGaGTPxg27VVRMYJGTq05dTsPi6t6gWvgMRWTNL83c4,697
4
+ blade_auth_client/casdoor/__init__.py,sha256=vv2YHkjOvo17fllrwB6MuQM8wTq3_hiP1O0zDUYrYtQ,57
5
+ blade_auth_client/casdoor/client.py,sha256=_RJAYWivYDl6DUptYim0-d09dZNX5rTWO4P9m8krh0I,2726
6
+ blade_auth_client/cookie/__init__.py,sha256=Rkr2m-sqWkL7MQipC-vrsn5s6CjlBGTlf0wAXqIjN54,165
7
+ blade_auth_client/cookie/policy.py,sha256=RdIpAQtmWrXmr40u7f_4rtqz0tpVLC_c1hqEEd1S3F8,1033
8
+ blade_auth_client/fastapi/__init__.py,sha256=So7Jrw4GBuwdXk5lIfEUy6fiS2S5dwejkLGLfZ6VYYY,503
9
+ blade_auth_client/fastapi/deps.py,sha256=GRSSx84NkvCOFgCSnnYxh-Ua-zRI17jpWzg_D2OAHqE,5124
10
+ blade_auth_client/fastapi/router.py,sha256=PPbHYTLwQGtOl4iWtZN8GrffZkmrbEz5mlHchUgc99E,10330
11
+ blade_auth_client/fastapi/urls.py,sha256=QoEbABsSMYOi6DGcr_2fsPOvLEfA3V67WFLUZeEtOnw,1346
12
+ blade_auth_client/socketio/__init__.py,sha256=RWTH5J0CXVRTidR5OnFObFISvdjdjH6YtAUc0Z_9tDU,147
13
+ blade_auth_client/socketio/middleware.py,sha256=-WDQ6infIHLycZMXgwJ3gwpQi-pwXTaR0BbGtOiwJ4M,3192
14
+ blade_auth_client/users/__init__.py,sha256=oni6WJ7T1Ka-845Hl7MjkFbfVVC1-bJ2Ul4X4jvIkNc,187
15
+ blade_auth_client/users/provisioner.py,sha256=JrX7NZVqFxANY6xCWOCsNLaUkig8kd5EUryp4PVqxOY,533
16
+ blade_auth_client/users/sqlalchemy_provisioner.py,sha256=Jhe7GJT6k_3ejsX0aUkdHuY4uryw-sJkKPCs8MijJTA,4129
17
+ blade_auth_client/verifier/__init__.py,sha256=rpEVqBAPueWw0RwaqIH1vhM0dzveXqM_WZ3oQe_25oY,148
18
+ blade_auth_client/verifier/jwks_cache.py,sha256=n9VRNbe_oeUA1Y6oOmP9wb81eyO_kdV61nLxyw2nWgw,3058
19
+ blade_auth_client/verifier/token_verifier.py,sha256=4XfV0Fz7MSSNk2d79BEkaSxSNRX_CH4eMRlN0r4YS2E,1739
20
+ blade_auth_client-0.2.0.dist-info/METADATA,sha256=faXG-6keF4oA1cUaMRFAYuRX7rzvTFWZ_DmgpPAN_k8,1906
21
+ blade_auth_client-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
22
+ blade_auth_client-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any