refactor(keycloak): remove authentik naming and switch to keycloak-only paths
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.auth import AuthentikPrincipal
|
||||
from app.security.authentik_jwt import require_authenticated_principal
|
||||
from app.schemas.auth import KeycloakPrincipal
|
||||
from app.security.idp_jwt import require_authenticated_principal
|
||||
|
||||
|
||||
def require_admin_principal(
|
||||
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
|
||||
) -> AuthentikPrincipal:
|
||||
principal: KeycloakPrincipal = Depends(require_authenticated_principal),
|
||||
) -> KeycloakPrincipal:
|
||||
settings = get_settings()
|
||||
required_groups = {group.lower() for group in settings.admin_required_groups}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
@@ -8,17 +9,19 @@ from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.auth import AuthentikPrincipal
|
||||
from app.schemas.auth import KeycloakPrincipal
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthentikTokenVerifier:
|
||||
class KeycloakTokenVerifier:
|
||||
def __init__(
|
||||
self,
|
||||
issuer: str | None,
|
||||
jwks_url: str | None,
|
||||
audience: str | None,
|
||||
client_id: str | None,
|
||||
client_secret: str | None,
|
||||
base_url: str | None,
|
||||
userinfo_endpoint: str | None,
|
||||
@@ -27,6 +30,7 @@ class AuthentikTokenVerifier:
|
||||
self.issuer = issuer.strip() if issuer else None
|
||||
self.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
|
||||
self.audience = audience.strip() if audience else None
|
||||
self.client_id = client_id.strip() if client_id else None
|
||||
self.client_secret = client_secret.strip() if client_secret else None
|
||||
self.base_url = base_url.strip() if base_url else None
|
||||
self.userinfo_endpoint = (
|
||||
@@ -35,36 +39,59 @@ class AuthentikTokenVerifier:
|
||||
self.verify_tls = verify_tls
|
||||
|
||||
if not self.jwks_url:
|
||||
raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
|
||||
raise ValueError("KEYCLOAK_JWKS_URL or KEYCLOAK_ISSUER is required")
|
||||
|
||||
self._jwk_client = jwt.PyJWKClient(self.jwks_url)
|
||||
|
||||
@staticmethod
|
||||
def _infer_introspection_endpoint(issuer: str | None) -> str | None:
|
||||
if not issuer:
|
||||
return None
|
||||
normalized = issuer.rstrip("/")
|
||||
if "/realms/" in normalized:
|
||||
return normalized + "/protocol/openid-connect/token/introspect"
|
||||
return None
|
||||
|
||||
def _introspect_token(self, token: str) -> dict | None:
|
||||
endpoint = self._infer_introspection_endpoint(self.issuer)
|
||||
if not endpoint or not self.client_id or not self.client_secret:
|
||||
return None
|
||||
try:
|
||||
resp = httpx.post(
|
||||
endpoint,
|
||||
timeout=8,
|
||||
verify=self.verify_tls,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
data={
|
||||
"token": token,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if resp.status_code >= 400:
|
||||
return None
|
||||
data = resp.json() if resp.content else {}
|
||||
if not isinstance(data, dict) or not data.get("active"):
|
||||
return None
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _infer_jwks_url(issuer: str | None) -> str | None:
|
||||
if not issuer:
|
||||
return None
|
||||
normalized = issuer.rstrip("/") + "/"
|
||||
if normalized.endswith("/jwks/"):
|
||||
return normalized
|
||||
return normalized + "jwks/"
|
||||
return issuer.rstrip("/") + "/protocol/openid-connect/certs"
|
||||
|
||||
@staticmethod
|
||||
def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None:
|
||||
if issuer:
|
||||
normalized = issuer.rstrip("/")
|
||||
if "/realms/" in normalized:
|
||||
return normalized + "/protocol/openid-connect/userinfo"
|
||||
marker = "/application/o/"
|
||||
marker_index = normalized.find(marker)
|
||||
if marker_index != -1:
|
||||
root = normalized[:marker_index]
|
||||
return root + marker + "userinfo/"
|
||||
return normalized + "/userinfo/"
|
||||
return issuer.rstrip("/") + "/protocol/openid-connect/userinfo"
|
||||
if base_url:
|
||||
return base_url.rstrip("/") + "/application/o/userinfo/"
|
||||
return base_url.rstrip("/") + "/realms/master/protocol/openid-connect/userinfo"
|
||||
return None
|
||||
|
||||
def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal:
|
||||
def _enrich_from_userinfo(self, principal: KeycloakPrincipal, token: str) -> KeycloakPrincipal:
|
||||
if principal.email and (principal.name or principal.preferred_username) and principal.groups:
|
||||
return principal
|
||||
if not self.userinfo_endpoint:
|
||||
@@ -97,7 +124,7 @@ class AuthentikTokenVerifier:
|
||||
payload_groups = data.get("groups")
|
||||
if isinstance(payload_groups, list):
|
||||
groups = [str(g) for g in payload_groups if str(g)]
|
||||
return AuthentikPrincipal(
|
||||
return KeycloakPrincipal(
|
||||
sub=principal.sub,
|
||||
email=email,
|
||||
name=name,
|
||||
@@ -105,7 +132,7 @@ class AuthentikTokenVerifier:
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
def verify_access_token(self, token: str) -> AuthentikPrincipal:
|
||||
def verify_access_token(self, token: str) -> KeycloakPrincipal:
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
algorithm = str(header.get("alg", "")).upper()
|
||||
@@ -120,7 +147,7 @@ class AuthentikTokenVerifier:
|
||||
if not self.client_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="missing_authentik_client_secret",
|
||||
detail="missing_idp_client_secret",
|
||||
)
|
||||
key = self.client_secret
|
||||
allowed_algorithms = ["HS256", "HS384", "HS512"]
|
||||
@@ -138,13 +165,17 @@ class AuthentikTokenVerifier:
|
||||
options=options,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc
|
||||
claims = self._introspect_token(token)
|
||||
if claims:
|
||||
logger.warning("jwt verify failed, used introspection fallback: %s", exc)
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc
|
||||
|
||||
sub = claims.get("sub")
|
||||
if not sub:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub")
|
||||
|
||||
principal = AuthentikPrincipal(
|
||||
principal = KeycloakPrincipal(
|
||||
sub=sub,
|
||||
email=claims.get("email"),
|
||||
name=claims.get("name"),
|
||||
@@ -155,12 +186,13 @@ class AuthentikTokenVerifier:
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_verifier() -> AuthentikTokenVerifier:
|
||||
def _get_verifier() -> KeycloakTokenVerifier:
|
||||
settings = get_settings()
|
||||
return AuthentikTokenVerifier(
|
||||
return KeycloakTokenVerifier(
|
||||
issuer=settings.idp_issuer,
|
||||
jwks_url=settings.idp_jwks_url,
|
||||
audience=settings.idp_audience,
|
||||
client_id=settings.idp_client_id,
|
||||
client_secret=settings.idp_client_secret,
|
||||
base_url=settings.idp_base_url,
|
||||
userinfo_endpoint=settings.idp_userinfo_endpoint,
|
||||
@@ -170,7 +202,7 @@ def _get_verifier() -> AuthentikTokenVerifier:
|
||||
|
||||
def require_authenticated_principal(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
) -> AuthentikPrincipal:
|
||||
) -> KeycloakPrincipal:
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_bearer_token")
|
||||
|
||||
Reference in New Issue
Block a user