495 lines
17 KiB
Python
495 lines
17 KiB
Python
from copy import deepcopy
|
|
from .exceptions import (
|
|
UnknownKid,
|
|
MissingJwksClaim,
|
|
MissingTrustMark,
|
|
TrustAnchorNeeded,
|
|
)
|
|
from .http_client import http_get
|
|
from .jwtse import verify_jws, unpad_jwt_head, unpad_jwt_payload
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import requests
|
|
|
|
|
|
try:
|
|
from django.conf import settings
|
|
except ImportError: # pragma: no cover
|
|
from . import settings
|
|
|
|
|
|
OIDCFED_FEDERATION_WELLKNOWN_URL = ".well-known/openid-federation"
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def jwks_from_jwks_uri(jwks_uri: str, httpc_params: dict = {}) -> list:
|
|
return [json.loads(asyncio.run(http_get([jwks_uri], httpc_params)))] # pragma: no cover
|
|
|
|
|
|
def get_federation_jwks(jwt_payload: dict, httpc_params: dict = {}):
|
|
return (
|
|
jwt_payload.get("jwks", {}).get("keys", [])
|
|
)
|
|
|
|
|
|
def get_http_url(urls: list, httpc_params: dict = {}) -> list:
|
|
if getattr(settings, "HTTP_CLIENT_SYNC", False):
|
|
responses = []
|
|
for i in urls:
|
|
res = requests.get(i, **httpc_params) # nosec - B113
|
|
body = res.content.decode()
|
|
if res.status_code != 200:
|
|
raise RuntimeError(
|
|
"HTTP GET failed for "
|
|
f"{i}: status={res.status_code} "
|
|
f"content_type={res.headers.get('Content-Type', '')} "
|
|
f"body_preview={body[:256].strip()}"
|
|
)
|
|
responses.append(body)
|
|
else:
|
|
responses = asyncio.run(http_get(urls, httpc_params)) # pragma: no cover
|
|
return responses
|
|
|
|
|
|
def get_entity_statements(urls: list, httpc_params: dict = {}) -> list:
|
|
"""
|
|
Fetches an entity statement/configuration
|
|
"""
|
|
if isinstance(urls, str):
|
|
urls = [urls] # pragma: no cover
|
|
for url in urls:
|
|
logger.debug(f"Starting Entity Statement Request to {url}")
|
|
return get_http_url(urls, httpc_params)
|
|
|
|
|
|
def get_entity_configurations(subjects: list, httpc_params: dict = {}):
|
|
if isinstance(subjects, str):
|
|
subjects = [subjects]
|
|
urls = []
|
|
for subject in subjects:
|
|
if subject[-1] != "/":
|
|
subject = f"{subject}/"
|
|
url = f"{subject}{OIDCFED_FEDERATION_WELLKNOWN_URL}"
|
|
urls.append(url)
|
|
logger.info(f"Starting Entity Configuration Request for {url}")
|
|
return get_http_url(urls, httpc_params)
|
|
|
|
|
|
def validate_entity_statement_header(header: dict) -> None:
|
|
"""
|
|
OpenID Federation 3.2: Entity Statements MUST have typ entity-statement+jwt
|
|
and kid (Key ID) in the header. Trust Marks are not validated here (typ can be customized).
|
|
"""
|
|
if header.get("typ") != "entity-statement+jwt":
|
|
raise ValueError(
|
|
f"Entity Statement must have typ=entity-statement+jwt, got {header.get('typ')}"
|
|
)
|
|
kid = header.get("kid")
|
|
if not kid or not isinstance(kid, str) or not kid.strip():
|
|
raise ValueError("Entity Statement must have non-empty kid header")
|
|
|
|
|
|
def get_trust_mark_type_id(payload_or_obj) -> str:
|
|
"""
|
|
Return trust mark type identifier from a Trust Mark JWT payload or from
|
|
a trust_marks array element (dict with trust_mark_type, trust_mark_id, or id).
|
|
Retrocompat: accepts trust_mark_type (draft 48), trust_mark_id, or id.
|
|
"""
|
|
if hasattr(payload_or_obj, "get"):
|
|
return (
|
|
payload_or_obj.get("trust_mark_type")
|
|
or payload_or_obj.get("trust_mark_id")
|
|
or payload_or_obj.get("id")
|
|
)
|
|
return None
|
|
|
|
|
|
class TrustMark:
|
|
def __init__(self, jwt: str, httpc_params: dict = {}):
|
|
self.jwt = jwt
|
|
self.header = unpad_jwt_head(jwt)
|
|
self.payload = unpad_jwt_payload(jwt)
|
|
|
|
# Retrocompat: id from trust_mark_type (draft 48), trust_mark_id, or id
|
|
self.id = get_trust_mark_type_id(self.payload)
|
|
if not self.id:
|
|
raise KeyError(
|
|
"Trust Mark payload must contain one of: "
|
|
"trust_mark_type, trust_mark_id, id"
|
|
)
|
|
self.sub = self.payload["sub"]
|
|
self.iss = self.payload["iss"]
|
|
|
|
self.is_valid = False
|
|
|
|
self.issuer_entity_configuration = None
|
|
self.httpc_params = httpc_params
|
|
|
|
def validate_by(self, ec) -> bool:
|
|
# TODO: pydantic entity configuration validation here
|
|
|
|
if self.header.get("kid") not in ec.kids:
|
|
raise UnknownKid( # pragma: no cover
|
|
f"Trust Mark validation failed: "
|
|
f"{self.header.get('kid')} not found in {ec.jwks}"
|
|
)
|
|
# verify signature
|
|
payload = verify_jws(self.jwt, ec.jwks[ec.kids.index(self.header["kid"])])
|
|
self.is_valid = True
|
|
return payload
|
|
|
|
def validate_by_its_issuer(self) -> bool:
|
|
if not self.issuer_entity_configuration:
|
|
self.issuer_entity_configuration = get_entity_configurations(
|
|
self.iss, self.httpc_params
|
|
)
|
|
try:
|
|
ec = EntityConfiguration(self.issuer_entity_configuration[0])
|
|
ec.validate_by_itself()
|
|
except UnknownKid:
|
|
logger.warning(
|
|
f"Trust Mark validation failed by its Issuer: "
|
|
f"{self.header.get('kid')} not found in "
|
|
f"{self.issuer_entity_configuration.jwks}")
|
|
return False
|
|
except Exception:
|
|
logger.warning(f"Issuer {self.iss} of trust mark {self.id} is not valid.")
|
|
self.is_valid = False
|
|
return False
|
|
|
|
# verify signature
|
|
payload = verify_jws(
|
|
self.jwt,
|
|
ec.jwks[
|
|
ec.kids.index(self.header["kid"])
|
|
],
|
|
)
|
|
self.is_valid = True
|
|
return payload
|
|
|
|
def __repr__(self) -> str:
|
|
return f"{self.id} to {self.sub} issued by {self.iss}"
|
|
|
|
|
|
class EntityConfiguration:
|
|
"""
|
|
The self issued/signed statement of a federation entity
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
jwt: str,
|
|
httpc_params: dict = {},
|
|
filter_by_allowed_trust_marks: list = [],
|
|
trust_anchor_entity_conf=None,
|
|
trust_mark_issuers_entity_confs: dict = [],
|
|
):
|
|
self.jwt = jwt
|
|
self.header = unpad_jwt_head(jwt)
|
|
validate_entity_statement_header(self.header)
|
|
self.payload = unpad_jwt_payload(jwt)
|
|
self.sub = self.payload["sub"]
|
|
self.iss = self.payload["iss"]
|
|
self.jwks = get_federation_jwks(self.payload, httpc_params)
|
|
if not self.jwks or not self.jwks[0]:
|
|
_msg = f"Missing jwks in the statement for {self.sub}"
|
|
logger.error(_msg)
|
|
raise MissingJwksClaim(_msg)
|
|
|
|
self.kids = [i.get("kid") for i in self.jwks]
|
|
self.httpc_params = httpc_params
|
|
|
|
self.filter_by_allowed_trust_marks = filter_by_allowed_trust_marks
|
|
self.trust_anchor_entity_conf = trust_anchor_entity_conf
|
|
self.trust_mark_issuers_entity_confs = trust_mark_issuers_entity_confs
|
|
|
|
# a dict with sup_sub : superior entity configuration
|
|
self.verified_superiors = {}
|
|
# as previous but with superiors with invalid entity configurations
|
|
self.failed_superiors = {}
|
|
|
|
# a dict with sup_sub : entity statement issued for self
|
|
self.verified_by_superiors = {}
|
|
self.failed_by_superiors = {}
|
|
|
|
# a dict with the paylaod of valid entity statements for each descendant subject
|
|
self.verified_descendant_statements = {}
|
|
self.failed_descendant_statements = {}
|
|
|
|
# a dict with the RAW JWT of valid entity statements for each descendant subject
|
|
self.verified_descendant_statements_as_jwt = {}
|
|
|
|
self.verified_trust_marks = []
|
|
self.is_valid = False
|
|
|
|
def validate_by_itself(self) -> bool:
|
|
"""
|
|
validates the entity configuration by it self
|
|
"""
|
|
# TODO: pydantic entity configuration validation here
|
|
if self.header.get("kid") not in self.kids:
|
|
raise UnknownKid(f"{self.header.get('kid')} not found in {self.jwks}") # pragma: no cover
|
|
# verify signature
|
|
verify_jws(self.jwt, self.jwks[self.kids.index(self.header["kid"])])
|
|
self.is_valid = True
|
|
return True
|
|
|
|
def validate_by_allowed_trust_marks(self) -> bool:
|
|
"""
|
|
validate the entity configuration ony if marked by a well known
|
|
trust mark, issued by a trusted issuer
|
|
"""
|
|
|
|
if not self.trust_anchor_entity_conf:
|
|
raise TrustAnchorNeeded(
|
|
"To validate the trust marks the "
|
|
"Trust Anchor Entity Configuration "
|
|
"is needed."
|
|
)
|
|
|
|
if not self.filter_by_allowed_trust_marks:
|
|
return True
|
|
|
|
if not self.payload.get("trust_marks"):
|
|
logger.warning(
|
|
f"{self.sub} doesn't have the trust marks claim "
|
|
"in its Entity Configuration"
|
|
)
|
|
return False
|
|
|
|
trust_marks = []
|
|
is_valid = False
|
|
for tm in self.payload["trust_marks"]:
|
|
# Retrocompat: accept trust_mark_type, trust_mark_id, or id
|
|
tm_type_id = get_trust_mark_type_id(tm)
|
|
if tm_type_id not in self.filter_by_allowed_trust_marks:
|
|
continue
|
|
|
|
try:
|
|
trust_mark = TrustMark(tm["trust_mark"])
|
|
except KeyError:
|
|
logger.warning(
|
|
f"Trust Mark decoding failed on [{tm}]. "
|
|
"Missing 'trust_mark' claim in it"
|
|
)
|
|
except Exception:
|
|
logger.warning(f"Trust Mark decoding failed on [{tm}]")
|
|
continue
|
|
else:
|
|
trust_marks.append(trust_mark)
|
|
|
|
if not trust_marks:
|
|
raise MissingTrustMark("Required Trust marks are missing.") # pragma: no cover
|
|
|
|
trust_mark_issuers_by_id = self.trust_anchor_entity_conf.payload.get(
|
|
"trust_mark_issuers", {}
|
|
)
|
|
|
|
# TODO : cache of issuers -> it would be better to have a proxy function
|
|
#
|
|
# required_issuer_ecs = []
|
|
# for trust_mark in trust_marks:
|
|
# if trust_mark.iss not in [
|
|
# i.payload.get('iss', None)
|
|
# for i in self.trust_mark_issuers_entity_confs
|
|
# ]:
|
|
# required_issuer_ecs.append(trust_mark.iss)
|
|
# TODO: snippet for CACHE
|
|
# if required_issuer_ec:
|
|
# ## fetch the issuer entity configuration and validate it
|
|
# iecs = get_entity_configurations(
|
|
# [required_issuer_ecs], self.httpc_params
|
|
# )
|
|
# for jwt in iecs:
|
|
# try:
|
|
# ec = self.__class__(jwt, httpc_params=self.httpc_params)
|
|
# ec.validate_by_itself()
|
|
# except Exception as e:
|
|
# logger.warning(
|
|
# "Trust Marks issuer Entity Configuration "
|
|
# f"failed for {jwt}: {e}"
|
|
# )
|
|
# continue
|
|
# self.trust_mark_issuers_entity_confs.append(ec)
|
|
|
|
for trust_mark in trust_marks:
|
|
id_issuers = trust_mark_issuers_by_id.get(trust_mark.id, None)
|
|
if id_issuers and trust_mark.iss not in id_issuers:
|
|
is_valid = False
|
|
elif id_issuers and trust_mark.iss in id_issuers:
|
|
is_valid = trust_mark.validate_by_its_issuer()
|
|
elif not id_issuers:
|
|
is_valid = trust_mark.validate_by(self.trust_anchor_entity_conf)
|
|
|
|
if not trust_mark.is_valid:
|
|
is_valid = False
|
|
|
|
if is_valid:
|
|
logger.info(f"Trust Mark {trust_mark} is valid")
|
|
self.verified_trust_marks.append(trust_mark)
|
|
else:
|
|
logger.warning(f"Trust Mark {trust_mark} is not valid")
|
|
|
|
return is_valid
|
|
|
|
def get_superiors(
|
|
self,
|
|
authority_hints: list = [],
|
|
max_authority_hints: int = 0,
|
|
superiors_hints: list = [],
|
|
) -> dict:
|
|
"""
|
|
get superiors entity configurations
|
|
"""
|
|
# apply limits if defined
|
|
authority_hints = authority_hints or deepcopy(self.payload.get("authority_hints", []))
|
|
if (
|
|
max_authority_hints
|
|
and authority_hints != authority_hints[:max_authority_hints]
|
|
):
|
|
logger.warning(
|
|
f"Found {len(authority_hints)} but "
|
|
f"authority maximum hints is set to {max_authority_hints}. "
|
|
"the following authorities will be ignored: "
|
|
f"{', '.join(authority_hints[max_authority_hints:])}"
|
|
)
|
|
authority_hints = authority_hints[:max_authority_hints]
|
|
|
|
for sup in superiors_hints:
|
|
if sup.sub in authority_hints:
|
|
logger.info(
|
|
"Getting Cached Entity Configurations for "
|
|
f"{[i.sub for i in superiors_hints]}"
|
|
)
|
|
authority_hints.pop(authority_hints.index(sup.sub))
|
|
self.verified_superiors[sup.sub] = sup
|
|
|
|
logger.debug(f"Getting Entity Configurations for {authority_hints}")
|
|
|
|
jwts = get_entity_configurations(authority_hints, self.httpc_params)
|
|
for jwt in jwts:
|
|
try:
|
|
ec = self.__class__(jwt, httpc_params=self.httpc_params)
|
|
except Exception as e:
|
|
logger.warning(f"Get Entity Configuration for {jwt}: {e}")
|
|
continue
|
|
|
|
if ec.validate_by_itself():
|
|
target = self.verified_superiors
|
|
else:
|
|
target = self.failed_superiors
|
|
|
|
target[ec.payload["sub"]] = ec
|
|
|
|
for ahints in authority_hints:
|
|
if not self.verified_superiors.get(ahints, None):
|
|
logger.warning(
|
|
f"{ahints} is not available, missing or not valid authority hint"
|
|
)
|
|
continue
|
|
|
|
return self.verified_superiors
|
|
|
|
def validate_descendant_statement(self, jwt: str) -> bool:
|
|
"""
|
|
jwt is a descendant entity statement issued by self
|
|
"""
|
|
header = unpad_jwt_head(jwt)
|
|
validate_entity_statement_header(header)
|
|
payload = unpad_jwt_payload(jwt)
|
|
|
|
if header.get("kid") not in self.kids:
|
|
raise UnknownKid(f"{self.header.get('kid')} not found in {self.jwks}")
|
|
# verify signature
|
|
payload = verify_jws(jwt, self.jwks[self.kids.index(header["kid"])])
|
|
|
|
self.verified_descendant_statements[payload["sub"]] = payload
|
|
self.verified_descendant_statements_as_jwt[payload["sub"]] = jwt
|
|
return self.verified_descendant_statements
|
|
|
|
def validate_by_superior_statement(self, jwt: str, ec):
|
|
"""
|
|
jwt is a statement issued by a superior
|
|
ec is a superior entity configuration
|
|
|
|
this method validates self with the jwks contained in statement
|
|
of the superior
|
|
"""
|
|
is_valid = None
|
|
payload = {}
|
|
try:
|
|
payload = unpad_jwt_payload(jwt)
|
|
ec.validate_by_itself()
|
|
ec.validate_descendant_statement(jwt)
|
|
_jwks = get_federation_jwks(payload, self.httpc_params)
|
|
_kids = [i.get("kid") for i in _jwks]
|
|
verify_jws(self.jwt, _jwks[_kids.index(self.header["kid"])])
|
|
is_valid = True
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"{self.sub} failed validation with "
|
|
f"{ec.sub}'s superior statement '{payload or jwt}'. "
|
|
f"Exception: {e}"
|
|
)
|
|
is_valid = False
|
|
|
|
if is_valid:
|
|
target = self.verified_by_superiors
|
|
ec.verified_descendant_statements[self.sub] = payload
|
|
ec.verified_descendant_statements_as_jwt[self.sub] = jwt
|
|
target[payload["iss"]] = ec
|
|
self.is_valid = True
|
|
return self.verified_by_superiors.get(ec.sub)
|
|
else:
|
|
target = self.failed_superiors
|
|
ec.failed_descendant_statements[self.sub] = payload
|
|
self.is_valid = False
|
|
|
|
def validate_by_superiors(
|
|
self,
|
|
superiors_entity_configurations: dict = {},
|
|
): # -> dict[str, EntityConfiguration]:
|
|
"""
|
|
validates the entity configuration with the entity statements
|
|
issued by its superiors
|
|
|
|
this methods create self.verified_superiors and failed ones
|
|
and self.verified_by_superiors and failed ones
|
|
"""
|
|
for ec in superiors_entity_configurations:
|
|
if ec.sub in ec.verified_by_superiors:
|
|
# already fetched and cached
|
|
continue
|
|
|
|
try:
|
|
# get superior fetch url
|
|
fetch_api_url = ec.payload["metadata"]["federation_entity"][
|
|
"federation_fetch_endpoint"
|
|
]
|
|
except KeyError:
|
|
logger.warning(
|
|
"Missing federation_fetch_endpoint in "
|
|
f"federation_entity metadata for {self.sub} by {ec.sub}."
|
|
)
|
|
self.failed_superiors[ec.sub] = None
|
|
continue
|
|
|
|
else:
|
|
_url = f"{fetch_api_url}?sub={self.sub}"
|
|
logger.info(f"Getting entity statements from {_url}")
|
|
jwts = get_entity_statements([_url], self.httpc_params)
|
|
jwt = jwts[0]
|
|
if jwt:
|
|
self.validate_by_superior_statement(jwt, ec)
|
|
else:
|
|
logger.error(
|
|
f"Empty response for {_url}"
|
|
)
|
|
|
|
return self.verified_by_superiors
|
|
|
|
def __repr__(self) -> str:
|
|
return f"{self.sub} valid {self.is_valid}"
|