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.
- blade_auth_client-0.2.0/.gitignore +19 -0
- blade_auth_client-0.2.0/PKG-INFO +66 -0
- blade_auth_client-0.2.0/README.md +41 -0
- blade_auth_client-0.2.0/pyproject.toml +46 -0
- blade_auth_client-0.2.0/src/blade_auth_client/__init__.py +73 -0
- blade_auth_client-0.2.0/src/blade_auth_client/casdoor/__init__.py +3 -0
- blade_auth_client-0.2.0/src/blade_auth_client/casdoor/client.py +80 -0
- blade_auth_client-0.2.0/src/blade_auth_client/config.py +22 -0
- blade_auth_client-0.2.0/src/blade_auth_client/cookie/__init__.py +3 -0
- blade_auth_client-0.2.0/src/blade_auth_client/cookie/policy.py +36 -0
- blade_auth_client-0.2.0/src/blade_auth_client/errors.py +29 -0
- blade_auth_client-0.2.0/src/blade_auth_client/fastapi/__init__.py +23 -0
- blade_auth_client-0.2.0/src/blade_auth_client/fastapi/deps.py +167 -0
- blade_auth_client-0.2.0/src/blade_auth_client/fastapi/router.py +283 -0
- blade_auth_client-0.2.0/src/blade_auth_client/fastapi/urls.py +45 -0
- blade_auth_client-0.2.0/src/blade_auth_client/socketio/__init__.py +3 -0
- blade_auth_client-0.2.0/src/blade_auth_client/socketio/middleware.py +110 -0
- blade_auth_client-0.2.0/src/blade_auth_client/users/__init__.py +4 -0
- blade_auth_client-0.2.0/src/blade_auth_client/users/provisioner.py +21 -0
- blade_auth_client-0.2.0/src/blade_auth_client/users/sqlalchemy_provisioner.py +112 -0
- blade_auth_client-0.2.0/src/blade_auth_client/verifier/__init__.py +4 -0
- blade_auth_client-0.2.0/src/blade_auth_client/verifier/jwks_cache.py +88 -0
- blade_auth_client-0.2.0/src/blade_auth_client/verifier/token_verifier.py +48 -0
- blade_auth_client-0.2.0/tests/conftest.py +87 -0
- blade_auth_client-0.2.0/tests/test_casdoor.py +46 -0
- blade_auth_client-0.2.0/tests/test_config.py +49 -0
- blade_auth_client-0.2.0/tests/test_cookie.py +62 -0
- blade_auth_client-0.2.0/tests/test_fastapi_deps.py +164 -0
- blade_auth_client-0.2.0/tests/test_fastapi_router.py +364 -0
- blade_auth_client-0.2.0/tests/test_socketio.py +199 -0
- blade_auth_client-0.2.0/tests/test_sqlalchemy_provisioner.py +96 -0
- blade_auth_client-0.2.0/tests/test_users.py +22 -0
- blade_auth_client-0.2.0/tests/test_verifier.py +177 -0
- 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,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,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
|