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.
- blade_auth_client/__init__.py +73 -0
- blade_auth_client/casdoor/__init__.py +3 -0
- blade_auth_client/casdoor/client.py +80 -0
- blade_auth_client/config.py +22 -0
- blade_auth_client/cookie/__init__.py +3 -0
- blade_auth_client/cookie/policy.py +36 -0
- blade_auth_client/errors.py +29 -0
- blade_auth_client/fastapi/__init__.py +23 -0
- blade_auth_client/fastapi/deps.py +167 -0
- blade_auth_client/fastapi/router.py +283 -0
- blade_auth_client/fastapi/urls.py +45 -0
- blade_auth_client/socketio/__init__.py +3 -0
- blade_auth_client/socketio/middleware.py +110 -0
- blade_auth_client/users/__init__.py +4 -0
- blade_auth_client/users/provisioner.py +21 -0
- blade_auth_client/users/sqlalchemy_provisioner.py +112 -0
- blade_auth_client/verifier/__init__.py +4 -0
- blade_auth_client/verifier/jwks_cache.py +88 -0
- blade_auth_client/verifier/token_verifier.py +48 -0
- blade_auth_client-0.2.0.dist-info/METADATA +66 -0
- blade_auth_client-0.2.0.dist-info/RECORD +22 -0
- blade_auth_client-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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
|
|
@@ -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,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,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,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,,
|