blade-auth-client 0.2.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.
Files changed (34) hide show
  1. blade_auth_client-0.2.0/.gitignore +19 -0
  2. blade_auth_client-0.2.0/PKG-INFO +66 -0
  3. blade_auth_client-0.2.0/README.md +41 -0
  4. blade_auth_client-0.2.0/pyproject.toml +46 -0
  5. blade_auth_client-0.2.0/src/blade_auth_client/__init__.py +73 -0
  6. blade_auth_client-0.2.0/src/blade_auth_client/casdoor/__init__.py +3 -0
  7. blade_auth_client-0.2.0/src/blade_auth_client/casdoor/client.py +80 -0
  8. blade_auth_client-0.2.0/src/blade_auth_client/config.py +22 -0
  9. blade_auth_client-0.2.0/src/blade_auth_client/cookie/__init__.py +3 -0
  10. blade_auth_client-0.2.0/src/blade_auth_client/cookie/policy.py +36 -0
  11. blade_auth_client-0.2.0/src/blade_auth_client/errors.py +29 -0
  12. blade_auth_client-0.2.0/src/blade_auth_client/fastapi/__init__.py +23 -0
  13. blade_auth_client-0.2.0/src/blade_auth_client/fastapi/deps.py +167 -0
  14. blade_auth_client-0.2.0/src/blade_auth_client/fastapi/router.py +283 -0
  15. blade_auth_client-0.2.0/src/blade_auth_client/fastapi/urls.py +45 -0
  16. blade_auth_client-0.2.0/src/blade_auth_client/socketio/__init__.py +3 -0
  17. blade_auth_client-0.2.0/src/blade_auth_client/socketio/middleware.py +110 -0
  18. blade_auth_client-0.2.0/src/blade_auth_client/users/__init__.py +4 -0
  19. blade_auth_client-0.2.0/src/blade_auth_client/users/provisioner.py +21 -0
  20. blade_auth_client-0.2.0/src/blade_auth_client/users/sqlalchemy_provisioner.py +112 -0
  21. blade_auth_client-0.2.0/src/blade_auth_client/verifier/__init__.py +4 -0
  22. blade_auth_client-0.2.0/src/blade_auth_client/verifier/jwks_cache.py +88 -0
  23. blade_auth_client-0.2.0/src/blade_auth_client/verifier/token_verifier.py +48 -0
  24. blade_auth_client-0.2.0/tests/conftest.py +87 -0
  25. blade_auth_client-0.2.0/tests/test_casdoor.py +46 -0
  26. blade_auth_client-0.2.0/tests/test_config.py +49 -0
  27. blade_auth_client-0.2.0/tests/test_cookie.py +62 -0
  28. blade_auth_client-0.2.0/tests/test_fastapi_deps.py +164 -0
  29. blade_auth_client-0.2.0/tests/test_fastapi_router.py +364 -0
  30. blade_auth_client-0.2.0/tests/test_socketio.py +199 -0
  31. blade_auth_client-0.2.0/tests/test_sqlalchemy_provisioner.py +96 -0
  32. blade_auth_client-0.2.0/tests/test_users.py +22 -0
  33. blade_auth_client-0.2.0/tests/test_verifier.py +177 -0
  34. blade_auth_client-0.2.0/uv.lock +683 -0
@@ -0,0 +1,19 @@
1
+ *.db
2
+ .env
3
+ app.conf
4
+ conf/app.conf
5
+ credentials.env
6
+ data/
7
+
8
+ # Python build artifacts (sdk/python)
9
+ __pycache__/
10
+ *.py[cod]
11
+ *.egg-info/
12
+ .venv/
13
+ .pytest_cache/
14
+ .ruff_cache/
15
+ .mypy_cache/
16
+ .coverage
17
+ htmlcov/
18
+ dist/
19
+ build/
@@ -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,41 @@
1
+ # blade-auth-client
2
+
3
+ `blade-auth-client` 是 Blade 生态的共享 Casdoor 认证客户端包。它抽离了统一的 token 校验、FastAPI 装配和 Socket.IO 握手接口,供各个服务复用。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pip install blade-auth-client[fastapi,socketio]
9
+ ```
10
+
11
+ ## Casdoor 配置要求
12
+
13
+ Casdoor 的 application、scope 和 redirect URI 约束见 [docs/casdoor-setup.md](docs/casdoor-setup.md)。
14
+
15
+ ## 快速上手
16
+
17
+ ```python
18
+ from fastapi import FastAPI
19
+
20
+ from blade_auth_client import (
21
+ AuthConfig,
22
+ OidcClient,
23
+ TokenVerifier,
24
+ create_auth_router,
25
+ make_current_user_dep,
26
+ )
27
+
28
+ app = FastAPI()
29
+ config = AuthConfig()
30
+ oidc_client = OidcClient(config)
31
+ verifier = TokenVerifier(config)
32
+ current_user = make_current_user_dep(config, verifier=verifier, provisioner=...)
33
+
34
+ app.include_router(
35
+ create_auth_router(config, verifier=verifier, provisioner=..., oidc_client=oidc_client)
36
+ )
37
+ ```
38
+
39
+ ## 版本策略
40
+
41
+ `0.0.x` 版本仅用于占位发布和联调验证,不承诺可用性。`0.2.0` 起采用仅校验 `iss` + 签名 + `exp` 的简化策略。
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "blade-auth-client"
7
+ version = "0.2.0"
8
+ description = "Shared Casdoor authentication client for Blade ecosystem services."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "yangliu35", email = "so.2liu@gmail.com" },
14
+ ]
15
+ dependencies = [
16
+ "httpx",
17
+ "pyjwt[crypto]",
18
+ "pydantic>=2",
19
+ "pydantic-settings",
20
+ "sqlalchemy",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ fastapi = [
25
+ "fastapi",
26
+ "starlette",
27
+ ]
28
+ socketio = [
29
+ "python-socketio",
30
+ ]
31
+ dev = [
32
+ "pytest",
33
+ "pytest-asyncio",
34
+ "respx",
35
+ "ruff",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://pypi.org/project/blade-auth-client/"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/blade_auth_client"]
43
+
44
+ [tool.ruff]
45
+ line-length = 100
46
+ target-version = "py312"
@@ -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