fix(auth): resolve admin groups via keycloak admin API when token lacks groups
This commit is contained in:
@@ -26,6 +26,10 @@ class KeycloakTokenVerifier:
|
|||||||
base_url: str | None,
|
base_url: str | None,
|
||||||
userinfo_endpoint: str | None,
|
userinfo_endpoint: str | None,
|
||||||
verify_tls: bool,
|
verify_tls: bool,
|
||||||
|
realm: str | None,
|
||||||
|
admin_realm: str | None,
|
||||||
|
admin_client_id: str | None,
|
||||||
|
admin_client_secret: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.issuer = issuer.strip() if issuer else None
|
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.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
|
||||||
@@ -33,6 +37,10 @@ class KeycloakTokenVerifier:
|
|||||||
self.client_id = client_id.strip() if client_id 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.client_secret = client_secret.strip() if client_secret else None
|
||||||
self.base_url = base_url.strip() if base_url else None
|
self.base_url = base_url.strip() if base_url else None
|
||||||
|
self.realm = realm.strip() if realm else None
|
||||||
|
self.admin_realm = admin_realm.strip() if admin_realm else self.realm
|
||||||
|
self.admin_client_id = admin_client_id.strip() if admin_client_id else None
|
||||||
|
self.admin_client_secret = admin_client_secret.strip() if admin_client_secret else None
|
||||||
self.userinfo_endpoint = (
|
self.userinfo_endpoint = (
|
||||||
userinfo_endpoint.strip() if userinfo_endpoint else self._infer_userinfo_endpoint(self.issuer, self.base_url)
|
userinfo_endpoint.strip() if userinfo_endpoint else self._infer_userinfo_endpoint(self.issuer, self.base_url)
|
||||||
)
|
)
|
||||||
@@ -95,7 +103,7 @@ class KeycloakTokenVerifier:
|
|||||||
if principal.email and (principal.name or principal.preferred_username) and principal.groups:
|
if principal.email and (principal.name or principal.preferred_username) and principal.groups:
|
||||||
return principal
|
return principal
|
||||||
if not self.userinfo_endpoint:
|
if not self.userinfo_endpoint:
|
||||||
return principal
|
return self._enrich_groups_from_admin(principal)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = httpx.get(
|
resp = httpx.get(
|
||||||
@@ -105,15 +113,15 @@ class KeycloakTokenVerifier:
|
|||||||
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
|
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return principal
|
return self._enrich_groups_from_admin(principal)
|
||||||
|
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
return principal
|
return self._enrich_groups_from_admin(principal)
|
||||||
|
|
||||||
data = resp.json() if resp.content else {}
|
data = resp.json() if resp.content else {}
|
||||||
sub = data.get("sub")
|
sub = data.get("sub")
|
||||||
if isinstance(sub, str) and sub and sub != principal.sub:
|
if isinstance(sub, str) and sub and sub != principal.sub:
|
||||||
return principal
|
return self._enrich_groups_from_admin(principal)
|
||||||
|
|
||||||
email = principal.email or (data.get("email") if isinstance(data.get("email"), str) else None)
|
email = principal.email or (data.get("email") if isinstance(data.get("email"), str) else None)
|
||||||
name = principal.name or (data.get("name") if isinstance(data.get("name"), str) else None)
|
name = principal.name or (data.get("name") if isinstance(data.get("name"), str) else None)
|
||||||
@@ -124,13 +132,85 @@ class KeycloakTokenVerifier:
|
|||||||
payload_groups = data.get("groups")
|
payload_groups = data.get("groups")
|
||||||
if isinstance(payload_groups, list):
|
if isinstance(payload_groups, list):
|
||||||
groups = [str(g) for g in payload_groups if str(g)]
|
groups = [str(g) for g in payload_groups if str(g)]
|
||||||
return KeycloakPrincipal(
|
enriched = KeycloakPrincipal(
|
||||||
sub=principal.sub,
|
sub=principal.sub,
|
||||||
email=email,
|
email=email,
|
||||||
name=name,
|
name=name,
|
||||||
preferred_username=preferred_username,
|
preferred_username=preferred_username,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
)
|
)
|
||||||
|
return self._enrich_groups_from_admin(enriched)
|
||||||
|
|
||||||
|
def _get_admin_token(self) -> str | None:
|
||||||
|
if (
|
||||||
|
not self.base_url
|
||||||
|
or not self.admin_realm
|
||||||
|
or not self.admin_client_id
|
||||||
|
or not self.admin_client_secret
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
token_endpoint = f"{self.base_url}/realms/{self.admin_realm}/protocol/openid-connect/token"
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": self.admin_client_id,
|
||||||
|
"client_secret": self.admin_client_secret,
|
||||||
|
},
|
||||||
|
timeout=6,
|
||||||
|
verify=self.verify_tls,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return None
|
||||||
|
token = resp.json().get("access_token")
|
||||||
|
return str(token) if token else None
|
||||||
|
|
||||||
|
def _enrich_groups_from_admin(self, principal: KeycloakPrincipal) -> KeycloakPrincipal:
|
||||||
|
if principal.groups:
|
||||||
|
return principal
|
||||||
|
if not self.base_url or not self.realm:
|
||||||
|
return principal
|
||||||
|
admin_token = self._get_admin_token()
|
||||||
|
if not admin_token:
|
||||||
|
return principal
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = httpx.get(
|
||||||
|
f"{self.base_url}/admin/realms/{self.realm}/users/{principal.sub}/groups",
|
||||||
|
timeout=6,
|
||||||
|
verify=self.verify_tls,
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}", "Accept": "application/json"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return principal
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return principal
|
||||||
|
|
||||||
|
payload = resp.json() if resp.content else []
|
||||||
|
groups: list[str] = []
|
||||||
|
if isinstance(payload, list):
|
||||||
|
for item in payload:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
path = item.get("path")
|
||||||
|
name = item.get("name")
|
||||||
|
if isinstance(path, str) and path:
|
||||||
|
groups.append(path)
|
||||||
|
elif isinstance(name, str) and name:
|
||||||
|
groups.append(name)
|
||||||
|
if not groups:
|
||||||
|
return principal
|
||||||
|
return KeycloakPrincipal(
|
||||||
|
sub=principal.sub,
|
||||||
|
email=principal.email,
|
||||||
|
name=principal.name,
|
||||||
|
preferred_username=principal.preferred_username,
|
||||||
|
groups=groups,
|
||||||
|
)
|
||||||
|
|
||||||
def verify_access_token(self, token: str) -> KeycloakPrincipal:
|
def verify_access_token(self, token: str) -> KeycloakPrincipal:
|
||||||
try:
|
try:
|
||||||
@@ -197,6 +277,10 @@ def _get_verifier() -> KeycloakTokenVerifier:
|
|||||||
base_url=settings.idp_base_url,
|
base_url=settings.idp_base_url,
|
||||||
userinfo_endpoint=settings.idp_userinfo_endpoint,
|
userinfo_endpoint=settings.idp_userinfo_endpoint,
|
||||||
verify_tls=settings.idp_verify_tls,
|
verify_tls=settings.idp_verify_tls,
|
||||||
|
realm=settings.keycloak_realm,
|
||||||
|
admin_realm=settings.keycloak_admin_realm,
|
||||||
|
admin_client_id=settings.keycloak_admin_client_id,
|
||||||
|
admin_client_secret=settings.keycloak_admin_client_secret,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user