feat: link local library

This commit is contained in:
gabdevele
2026-06-17 20:43:56 +02:00 Unverified
parent 9a770a2af0
commit db59882f33
327 changed files with 22274 additions and 1 deletions
@@ -0,0 +1,414 @@
import logging
import uuid
from cryptojwt.jws.utils import left_hash
from django.conf import settings
from pydantic import ValidationError
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import timezone
import urllib
from spid_cie_oidc.entity.jwtse import create_jws, unpad_jwt_head, unpad_jwt_payload, verify_jws
from spid_cie_oidc.entity.models import FederationEntityConfiguration, TrustChain
from spid_cie_oidc.entity.settings import HTTPC_PARAMS
from spid_cie_oidc.entity.trust_chain_operations import get_or_create_trust_chain
from spid_cie_oidc.entity.utils import datetime_from_timestamp, exp_from_now, iat_now
from spid_cie_oidc.entity.utils import get_jwks
from spid_cie_oidc.entity.exceptions import TrustchainMissingMetadata
from spid_cie_oidc.provider.exceptions import (
AuthzRequestReplay,
ExpiredAuthCode,
InvalidSession,
RevokedSession,
ValidationException
)
from spid_cie_oidc.provider.models import OidcSession
from spid_cie_oidc.provider.schemas.client_assertion import ClientAssertion
from spid_cie_oidc.provider.settings import (
OIDCFED_ATTRNAME_I18N,
OIDCFED_DEFAULT_PROVIDER_PROFILE,
OIDCFED_PROVIDER_AUTH_CODE_MAX_AGE,
OIDCFED_PROVIDER_PROFILES,
OIDCFED_PROVIDER_PROFILES_ACR_4_REFRESH,
OIDCFED_PROVIDER_PROFILES_ID_TOKEN_CLAIMS
)
logger = logging.getLogger(__name__)
class OpBase:
"""
Baseclass with common methods for OPs
"""
def redirect_response_data(self, redirect_uri: str, **kwargs) -> HttpResponseRedirect:
if "?" in redirect_uri:
qstring = "&"
else:
qstring = "?"
url = f'{redirect_uri}{qstring}{urllib.parse.urlencode(kwargs)}'
return HttpResponseRedirect(url)
def find_jwk(self, header: dict, jwks: list) -> dict:
for jwk in jwks:
valid_jwk = jwk.get("kid", None)
if valid_jwk and header["kid"] == valid_jwk:
return jwk
def validate_authz_request_object(self, req) -> TrustChain:
state = getattr(self, 'payload', {}).get("state", "")
try:
self.payload = unpad_jwt_payload(req)
header = unpad_jwt_head(req)
except Exception as e:
_msg = (
f"Error in Authz request object {dict(req.GET)}: {e}."
f" error=invalid_request"
f"state={state}"
)
logger.error(_msg)
raise Exception(_msg)
self.is_a_replay_authz()
rp_trust_chain = TrustChain.objects.filter(
metadata__openid_relying_party__isnull=False,
sub=self.payload["iss"],
trust_anchor__sub__in=settings.OIDCFED_TRUST_ANCHORS
).first()
if rp_trust_chain and not rp_trust_chain.is_active:
_msg = (
f"Disabled client {rp_trust_chain.sub} requests an authorization. "
"error = access_denied, "
f"state={state}"
)
logger.warning(_msg)
raise Exception(_msg)
elif not rp_trust_chain or rp_trust_chain.is_expired:
rp_trust_chain = None
# TODO: get async here
for ta in settings.OIDCFED_TRUST_ANCHORS:
try:
rp_trust_chain = get_or_create_trust_chain(
subject=self.payload["iss"],
trust_anchor=ta,
httpc_params=HTTPC_PARAMS,
required_trust_marks=getattr(
settings, "OIDCFED_REQUIRED_TRUST_MARKS", []
),
)
if rp_trust_chain and rp_trust_chain.metadata:
break
except TrustchainMissingMetadata as e:
logger.debug(f"TrustchainMissingMetadata: {e}")
# unless we find the good TA
continue
if not rp_trust_chain or not rp_trust_chain.is_valid:
_msg = (
f"Failed trust chain validation for {self.payload['iss']}. "
"error=unauthorized_client, "
f"state={state}"
)
logger.warning(_msg)
raise Exception(_msg)
jwks = get_jwks(
rp_trust_chain.metadata['openid_relying_party'],
federation_jwks=rp_trust_chain.jwks
)
jwk = self.find_jwk(header, jwks)
if not jwk:
_msg = (
f"Invalid jwk for {self.payload['iss']}. "
f"{header['kid']} not found in {jwks}. "
"error=unauthorized_client, "
f"state={state}"
)
logger.error(_msg)
raise Exception(_msg)
try:
verify_jws(req, jwk)
except Exception as e:
_msg = (
"Authz request object signature validation failed "
f"for {self.payload['iss']}: {e}. "
f"error=access_denied, state={state}"
)
logger.error(_msg)
raise Exception(_msg)
return rp_trust_chain
def is_a_replay_authz(self):
preexistent_authz = OidcSession.objects.filter(
client_id=self.payload["client_id"],
nonce=self.payload["nonce"]
).first()
if preexistent_authz:
raise AuthzRequestReplay(
f"{preexistent_authz.client_id} with {preexistent_authz.nonce}"
)
def check_session(self, request) -> OidcSession:
if not request.user.is_authenticated:
raise InvalidSession()
auth_code = request.session.get("oidc", {}).get("auth_code", None)
if not auth_code:
# FIXME: to do test
raise InvalidSession()
session = OidcSession.objects.filter(
auth_code=request.session["oidc"]["auth_code"]
).first()
if not session:
raise InvalidSession("Unknown Session")
if session.user != request.user:
raise InvalidSession(
f"Different user, session is of {session.user} "
f"but request user is {request.user}"
)
if session.revoked:
raise RevokedSession(
"Session was revoked"
)
session_not_after = session.created + timezone.timedelta(
minutes=OIDCFED_PROVIDER_AUTH_CODE_MAX_AGE
)
if session_not_after < timezone.localtime():
raise ExpiredAuthCode(
f"The Session is expired at {session_not_after}"
)
return session
def get_issuer(self):
return FederationEntityConfiguration.objects.filter(
entity_type="openid_provider"
).first()
def check_client_assertion(self, client_id: str, client_assertion: str) -> bool:
head = unpad_jwt_head(client_assertion)
payload = unpad_jwt_payload(client_assertion)
_sub = payload.get('sub', None)
_aud = payload.get('aud', [])
_op = self.get_issuer()
_op_eid = _op.sub
_op_eid_authz_endpoint = [_op.metadata['openid_provider']['authorization_endpoint']]
try:
ClientAssertion(**payload)
except Exception as e:
raise Exception(f"Client Assertion: json schema validation error: {e}")
if isinstance(_aud, str):
_aud = [_aud]
_allowed_auds = _aud + _op_eid_authz_endpoint
if _sub != client_id:
logger.warning(
f"Client assertion failed: {_sub} != {client_id}"
)
# TODO Specialize exceptions
raise Exception("Client Assertion: sub != client_id")
if _op_eid:
_allowed_auds.append(_op_eid)
if not _op_eid or self.request.build_absolute_uri() not in _allowed_auds:
logger.warning(
"Client assertion failed, fake audience: "
f"{self.request.build_absolute_uri()} not in {_allowed_auds}"
)
# TODO Specialize exceptions
raise Exception("Client Assertion: fake audience")
tc = TrustChain.objects.get(sub=client_id, is_active=True)
jwk = self.find_jwk(head, tc.metadata['openid_relying_party']['jwks']['keys'])
verify_jws(client_assertion, jwk)
return True
def validate_json_schema(self, payload, schema_type, error_description):
try:
schema = OIDCFED_PROVIDER_PROFILES[OIDCFED_DEFAULT_PROVIDER_PROFILE]
schema[schema_type](**payload)
except (ValidationError, Exception) as e:
logger.error(
f"{error_description} "
f"for {payload.get('client_id', None)}: {e}"
)
raise ValidationException()
def get_jwt_common_data(self):
return {
"exp": exp_from_now(),
"iat": iat_now()
}
def get_access_token(
self, iss_sub: str, sub: str, authz: OidcSession, commons: dict
) -> dict:
access_token = {
"iss": iss_sub,
"sub": sub,
"aud": [iss_sub, reverse("oidc_provider_userinfo_endpoint")],
"client_id": authz.client_id,
"scope": authz.authz_request["scope"],
"jti": str(uuid.uuid4())
}
access_token.update(commons)
return access_token
def get_id_token_claims(
self,
authz: OidcSession
) -> dict:
_provider_profile = getattr(settings, 'OIDCFED_DEFAULT_PROVIDER_PROFILE', OIDCFED_DEFAULT_PROVIDER_PROFILE)
claims = {}
allowed_id_token_claims = OIDCFED_PROVIDER_PROFILES_ID_TOKEN_CLAIMS[_provider_profile]
if not allowed_id_token_claims:
return claims
for claim in (
authz.authz_request.get(
"claims", {}
).get("id_token", {}).keys()
):
if claim in allowed_id_token_claims and authz.user.attributes.get(claim, None):
claims[claim] = authz.user.attributes[claim]
return claims
def get_id_token(
self,
iss_sub: str,
sub: str,
authz: OidcSession,
jwt_at: str,
commons: dict
) -> dict:
id_token = {
"sub": sub,
"nonce": authz.authz_request["nonce"],
"at_hash": left_hash(jwt_at, "HS256"),
"c_hash": left_hash(authz.auth_code, "HS256"),
"aud": [authz.client_id],
"iss": iss_sub,
"acr": authz.acr,
"jti": str(uuid.uuid4())
}
claims = self.get_id_token_claims(authz)
if claims:
id_token.update(claims)
id_token.update(commons)
return id_token
def get_refresh_token(
self,
iss_sub: str,
sub: str,
authz: OidcSession,
jwt_at: str,
commons: dict
) -> dict:
# refresh token is scope offline_access and prompt == consent
refresh_acrs = OIDCFED_PROVIDER_PROFILES_ACR_4_REFRESH[OIDCFED_DEFAULT_PROVIDER_PROFILE]
acrs = authz.authz_request.get('acr_values', [])
if (
"offline_access" in authz.authz_request['scope'] and
'consent' in authz.authz_request['prompt'] and
set(refresh_acrs).intersection(set(acrs))
):
refresh_token = {
"sub": sub,
"at_hash": left_hash(jwt_at, "HS256"),
"c_hash": left_hash(authz.auth_code, "HS256"),
"aud": authz.client_id,
"iss": iss_sub,
"jti": str(uuid.uuid4())
}
refresh_token.update(commons)
return refresh_token
def get_iss_token_data(self, session: OidcSession, issuer: FederationEntityConfiguration):
_sub = session.pairwised_sub(provider_id=issuer.sub)
iss_sub = issuer.sub
commons = self.get_jwt_common_data()
jwk = issuer.jwks_core[0]
access_token = self.get_access_token(iss_sub, _sub, session, commons)
jwt_at = create_jws(access_token, jwk, typ="at+jwt")
id_token = self.get_id_token(iss_sub, _sub, session, jwt_at, commons)
jwt_id = create_jws(id_token, jwk)
iss_token_data = dict(
session=session,
access_token=jwt_at,
id_token=jwt_id,
expires=datetime_from_timestamp(commons["exp"])
)
_refresh_token = self.get_refresh_token(iss_sub, _sub, session, jwt_at, commons)
if _refresh_token:
jwt_rt = create_jws(_refresh_token, jwk)
iss_token_data["refresh_token"] = jwt_rt
return iss_token_data
def get_expires_in(self, iat: int, exp: int):
return timezone.timedelta(
seconds=exp - iat
).seconds
def attributes_names_to_release(self, request, session: OidcSession) -> dict:
# get up fresh claims
user_claims = request.user.attributes
user_claims["email"] = user_claims.get("email", request.user.email)
user_claims["username"] = request.user.username
# filter on requested claims
filtered_user_claims = {}
for target, claims in session.authz_request.get("claims", {}).items():
for claim in claims:
if claim in user_claims:
filtered_user_claims[claim] = user_claims[claim]
# IDA support/overload of the claims, the verified has priorities and overwrite the unverified
if "verified_claims" in claims:
claims = claims["verified_claims"].get('claims', {})
for claim in claims:
if claim in user_claims:
filtered_user_claims[claim] = user_claims[claim]
# mapping with human names
i18n_user_claims = [
OIDCFED_ATTRNAME_I18N.get(i, i)
for i in filtered_user_claims.keys()
]
return dict(
i18n_user_claims=i18n_user_claims,
filtered_user_claims=filtered_user_claims
)
def get_client_organization_name(self, tc):
rp_metadata = (
tc.metadata.get(
"federation_entity", {}
) or
tc.metadata.get(
"openid_relying_party", {}
)
)
if rp_metadata:
name = (
rp_metadata.get("organization_name", "") or
rp_metadata.get("client_name", "") or
rp_metadata.get("client_id", "")
)
return name
@@ -0,0 +1,303 @@
import hashlib
import logging
import urllib.parse
import uuid
import json
from djagger.decorators import schema
from django.conf import settings
from django.contrib.auth import authenticate, login, logout
from django.forms.utils import ErrorList
from django.http import (
HttpResponseBadRequest,
HttpResponseForbidden,
HttpResponseRedirect
)
from django.shortcuts import render
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views import View
from spid_cie_oidc.entity.exceptions import InvalidEntityConfiguration
from spid_cie_oidc.provider.schemas.authn_requests import AcrValues
from spid_cie_oidc.provider.forms import AuthLoginForm, AuthzHiddenForm
from spid_cie_oidc.provider.models import OidcSession
from spid_cie_oidc.provider.exceptions import AuthzRequestReplay, InvalidRefreshRequestException, ValidationException
from spid_cie_oidc.provider.settings import (
OIDCFED_DEFAULT_PROVIDER_PROFILE,
OIDCFED_PROVIDER_PROFILES,
OIDCFED_PROVIDER_PROFILES_DEFAULT_ACR
)
from . import OpBase
logger = logging.getLogger(__name__)
schema_profile = OIDCFED_PROVIDER_PROFILES[OIDCFED_DEFAULT_PROVIDER_PROFILE]
@schema(
summary="OIDC Provider Authorization endpoint",
methods=['GET', 'POST'],
get_request_schema = {
"application/x-www-form-urlencoded": schema_profile["authorization_request_doc"],
"request object - jwt payload": schema_profile["authorization_request"]
},
post_response_schema= {
"302":schema_profile["authorization_response"],
"403": schema_profile["authorization_error_response"]
},
external_docs = {
"alt_text": "AgID SPID OIDC Guidelines",
"url": (
"https://www.agid.gov.it/it/agenzia/stampa-e-comunicazione/"
"notizie/2021/12/06/openid-connect-spid-adottate-linee-guida"
),
},
tags = ['Provider']
)
class AuthzRequestView(OpBase, View):
"""
View which processes the actual Authz request and
returns a Http Redirect
"""
template = "op_user_login.html"
def string_to_list(self, payload, must_list):
for i in must_list:
if isinstance(payload.get(i, None), str):
if ' ' in payload[i]:
payload[i] = payload[i].split(' ')
else:
payload[i] = [payload[i]]
return payload
def validate_authz(self, payload: dict):
must_list = ("scope", "acr_values")
payload = self.string_to_list(payload, must_list)
if (
'offline_access' in payload['scope'] and
'consent' not in payload['prompt']
):
raise InvalidRefreshRequestException(
"scope with offline_access without prompt = consent"
)
redirect_uri = payload.get("redirect_uri", "")
p = urllib.parse.urlparse(redirect_uri)
scheme_fqdn = f"{p.scheme}://{p.hostname}"
client_id = payload.get("client_id") or ""
if scheme_fqdn not in client_id:
raise ValidationException("client_id not in redirect_uri")
self.validate_json_schema(
payload,
"authorization_request",
"Authen request object validation failed "
)
def get_url_consent(self, user):
url = reverse("oidc_provider_consent")
if (
user.is_staff and
'spid_cie_oidc.relying_party_test' in settings.INSTALLED_APPS
):
try:
url = reverse("oidc_provider_staff_testing")
except Exception as e: # pragma: no cover
logger.error(f"testigng page url reverse failed: {e}")
return url
def get_login_form(self):
return AuthLoginForm
def get(self, request, *args, **kwargs):
"""
The Authorization request of a RPs is validated and a login prompt is rendered to the user
"""
req = request.GET.get("request", None)
if not req:
logger.error(
f"Missing Authz request object in {dict(request.GET)} "
f"error=invalid_request"
)
return HttpResponseBadRequest()
# yes, again. We MUST.
tc = None
try:
tc = self.validate_authz_request_object(req)
except InvalidEntityConfiguration as e:
# FIXME: to do test
logger.error(f"Invalid Entity Configuration: {e}")
return self.redirect_response_data(
self.payload["redirect_uri"],
error = "invalid_request",
error_description =_("Failed to establish the Trust"),
state = self.payload.get("state", "")
)
except AuthzRequestReplay as e:
logger.error(
"Replay on authz request detected for "
f"{request.GET.get('client_id', 'unknow')}: {e}"
)
return self.redirect_response_data(
self.payload["redirect_uri"],
error = "invalid_request",
error_description =_(
"An Unknown error raised during validation of "
f" authz request object: {e}"
),
state = self.payload.get("state", "")
)
except Exception as e:
logger.error(
"Error during authz request validation for "
f"{request.GET.get('client_id', 'unknown')}: {e}"
)
return self.redirect_response_data(
self.payload["redirect_uri"],
error="invalid_request",
error_description=_("Authorization request not valid"),
state = self.payload.get("state", "")
)
try:
self.validate_authz(self.payload)
except ValidationException:
return self.redirect_response_data(
self.payload["redirect_uri"],
error="invalid_request",
error_description=_("Authorization request validation error"),
state = self.payload.get("state", "")
)
except InvalidRefreshRequestException as e:
logger.warning(f"Invalid session: {e}")
return HttpResponseForbidden()
if self.payload.get("acr_values", None):
acr_value = AcrValues(self.payload["acr_values"][0])
else:
# set this as default
acr_value = AcrValues.l2
prompt = self.payload.get("prompt", "login")
if request.user:
if (
request.user.is_authenticated and
acr_value == AcrValues.l1 and
"login" not in prompt
):
try:
session = self.check_session(request)
if session.acr != AcrValues.l1.value:
logout(request)
return self.get(request)
else:
url = self.get_url_consent(request.user)
return HttpResponseRedirect(url)
except Exception:
logger.warning(
f"Failed SSO check session for {request.user}"
)
logout(request)
return self.get(request)
# stores the authz request in a hidden field in the form
form = self.get_login_form()()
context = {
"client_organization_name": self.get_client_organization_name(tc),
"hidden_form": AuthzHiddenForm(dict(authz_request_object=req)),
"form": form,
"redirect_uri": self.payload["redirect_uri"],
"obj_request": json.dumps(self.payload, indent=2),
"acr_value": acr_value.name,
"state": self.payload["state"]
}
return render(request, self.template, context)
def post(self, request, *args, **kwargs):
"""
When the User prompts his credentials
TODO: REFACTOR this method doesn't support PAR!
"""
form = self.get_login_form()(request.POST)
if not form.is_valid():
return render(
request,
self.template,
{
"form": form,
"hidden_form": AuthzHiddenForm(request.POST),
}
)
authz_form = AuthzHiddenForm(request.POST)
authz_form.is_valid()
authz_request = authz_form.cleaned_data.get("authz_request_object")
try:
self.validate_authz_request_object(authz_request)
except Exception as e:
logger.error(
"Authz request object validation failed "
f"for {authz_request}: {e} "
)
# we don't have a redirect_uri here
return HttpResponseForbidden()
# autenticate the user
username = form.cleaned_data.get("username")
password = form.cleaned_data.get("password")
user = authenticate(username=username, password=password)
if not user:
errors = form._errors.setdefault("username", ErrorList())
errors.append(_("invalid username or password"))
return render(
request,
self.template,
{
"form": form,
"hidden_form": AuthzHiddenForm(request.POST),
"redirect_uri": self.payload["redirect_uri"],
"state": self.payload["state"]
}
)
else:
login(request, user)
# create auth_code
auth_code = hashlib.sha512(
'-'.join(
(
f'{uuid.uuid4()}',
f'{self.payload["client_id"]}',
f'{self.payload["nonce"]}'
)
).encode()
).hexdigest()
# put the auth_code in the user web session
request.session["oidc"] = {"auth_code": auth_code}
# store the User session
_provider_profile = getattr(
settings,
'OIDCFED_DEFAULT_PROVIDER_PROFILE',
OIDCFED_DEFAULT_PROVIDER_PROFILE
)
default_acr = OIDCFED_PROVIDER_PROFILES_DEFAULT_ACR[_provider_profile]
self.payload = self.string_to_list(self.payload, ["acr_values"])
len_acr = len(self.payload.get("acr_values",[]))
session = OidcSession.objects.create(
user=user,
user_uid=user.username,
nonce=self.payload["nonce"],
authz_request=self.payload,
client_id=self.payload["client_id"],
auth_code=auth_code,
acr=(
self.payload["acr_values"][len_acr - 1]
if len_acr > 0
else default_acr
)
)
session.set_sid(request)
url = self.get_url_consent(user)
return HttpResponseRedirect(url)
@@ -0,0 +1,34 @@
import logging
from djagger.decorators import schema
from django.http import Http404
from django.http import JsonResponse
from spid_cie_oidc.entity.models import FederationEntityConfiguration
logger = logging.getLogger(__name__)
@schema(
summary="OIDC Provider openid-configuration",
methods=['GET'],
tags = ['Provider']
)
def openid_configuration(request):
"""
OIDC Discovery configuration at
.well-known/openid-configuration
"""
_sub = request.build_absolute_uri().split("/.well-known/openid-configuration")[0]
conf = FederationEntityConfiguration.objects.filter(
# TODO: check for reverse proxy and forwarders ...
sub=_sub,
is_active=True,
).first()
if not conf: # pragma: no cover
raise Http404()
content = conf.entity_configuration_as_dict['metadata'].get('openid_provider', None)
if content:
return JsonResponse(content, safe=False)
else:
raise Http404()
@@ -0,0 +1,157 @@
import logging
from django.core.paginator import Paginator
import urllib.parse
from urllib.parse import urlparse
from djagger.decorators import schema
from django.contrib.auth import logout
from django.http import (
HttpResponseForbidden,
HttpResponseRedirect,
)
from django.shortcuts import redirect, render
from django.utils.translation import gettext as _
from django.views import View
from spid_cie_oidc.entity.models import (
TrustChain
)
from spid_cie_oidc.provider.forms import ConsentPageForm
from spid_cie_oidc.provider.models import IssuedToken, OidcSession
from spid_cie_oidc.provider.settings import OIDCFED_PROVIDER_HISTORY_PER_PAGE
from . import OpBase
logger = logging.getLogger(__name__)
class ConsentPageView(OpBase, View):
template = "op_user_consent.html"
def get_consent_form(self):
return ConsentPageForm
def get(self, request, *args, **kwargs):
try:
session = self.check_session(request)
except Exception:
logger.warning("Invalid session on Consent page")
return HttpResponseForbidden()
tc = TrustChain.objects.filter(
sub=session.client_id,
metadata__openid_relying_party__isnull=False,
is_active = True
).first()
# if this auth code was already been used ... forbidden
if IssuedToken.objects.filter(session=session):
logger.warning(f"Auth code Replay {session}")
return HttpResponseForbidden()
i18n_user_claims = self.attributes_names_to_release(
request, session
)['i18n_user_claims']
context = {
"form": self.get_consent_form()(),
"session": session,
"client_organization_name": self.get_client_organization_name(tc),
"user_claims": sorted(set(i18n_user_claims),),
"redirect_uri": session.authz_request["redirect_uri"],
"state": session.authz_request["state"]
}
return render(request, self.template, context)
def post(self, request, *args, **kwargs):
try:
session = self.check_session(request)
except Exception as e:
logger.warning(f"Invalid session: {e}")
return HttpResponseForbidden()
self.payload = session.authz_request
form = self.get_consent_form()(request.POST)
if not form.is_valid():
return self.redirect_response_data(
self.payload["redirect_uri"],
# TODO: this is not normative -> check AgID/IPZS
error="rejected_by_user",
error_description=_("User rejected the release of attributes"),
state = self.payload.get("state", "")
)
issuer = self.get_issuer()
iss_token_data = self.get_iss_token_data(session, issuer)
IssuedToken.objects.create(**iss_token_data)
return self.redirect_response_data(
self.payload["redirect_uri"],
code=session.auth_code,
state=session.authz_request["state"],
iss=issuer.sub if issuer else "",
)
def oidc_provider_not_consent(request):
redirect_uri = urlparse(request.GET.get("redirect_uri"))
state = request.GET.get("state", "")
logout(request)
kwargs = dict(
error = "invalid_request",
error_description = _(
"Authentication request rejected by user"
),
state = state
)
path = redirect_uri.path if redirect_uri.path else "/"
base = f'{redirect_uri.scheme}://{redirect_uri.netloc}{path}'
url = f'{base}?{urllib.parse.urlencode(kwargs)}'
return HttpResponseRedirect(url)
class UserAccessHistoryView(OpBase, View):
def get(self, request, *args, **kwargs):
try:
session = self.check_session(request)
except Exception: # pragma: no cover
logger.warning("Invalid session on Access History page")
return HttpResponseForbidden()
user_access_history = OidcSession.objects.filter(
user_uid=session.user_uid,
).exclude(auth_code=session.auth_code)
paginator = Paginator(
user_access_history,
OIDCFED_PROVIDER_HISTORY_PER_PAGE
)
page = request.GET.get("page", 1)
history = paginator.get_page(page)
context = {
"history": history,
"user": request.user
}
return render(request, "op_user_history.html", context)
@schema(
methods = [],
djagger_exclude=True
)
class RevokeSessionView(OpBase, View):
def get(self, request, *args, **kwargs):
try:
self.check_session(request)
except Exception: # pragma: no cover
logger.warning("Invalid session on revoke page")
return HttpResponseForbidden()
if request.GET.get("auth_code"):
auth_code = request.GET["auth_code"]
session_to_revoke = OidcSession.objects.filter(
auth_code=auth_code,
user=request.user
).first()
session_to_revoke.revoke(destroy_session=False)
return redirect("oidc_provider_access_history")
@@ -0,0 +1,97 @@
import logging
from djagger.decorators import schema
from django.http import (
HttpResponseBadRequest,
HttpResponseForbidden,
JsonResponse
)
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from pydantic import BaseModel
from spid_cie_oidc.provider.exceptions import ValidationException
from spid_cie_oidc.provider.models import IssuedToken
from spid_cie_oidc.provider.settings import (
OIDCFED_DEFAULT_PROVIDER_PROFILE,
OIDCFED_PROVIDER_PROFILES
)
from . import OpBase
logger = logging.getLogger(__name__)
schema_profile = OIDCFED_PROVIDER_PROFILES[OIDCFED_DEFAULT_PROVIDER_PROFILE]
@schema(
methods=['GET', 'POST'],
post_request_schema = {
"application/x-www-form-urlencoded": schema_profile["introspection_request"]
},
post_response_schema= {
"200":schema_profile["introspection_response"],
"400":schema_profile["introspection_error_response"],
},
get_response_schema= {
"400": BaseModel
},
tags = ['Provider']
)
@method_decorator(csrf_exempt, name="dispatch")
class IntrospectionEndpoint(OpBase, View):
def get(self, request, *args, **kwargs):
return HttpResponseBadRequest()
def post(self, request, *args, **kwargs):
try:
self.validate_json_schema(
request.POST.dict(),
"introspection_request",
"Introspection request object validation failed"
)
client_id = request.POST['client_id']
self.check_client_assertion(
client_id,
request.POST['client_assertion']
)
except ValidationException:
return JsonResponse(
{
"error": "invalid_request",
"error_description": "Introspection request object validation failed",
},
status = 400
)
except Exception as e: # pragma: no cover
logger.error(e)
return HttpResponseForbidden()
required_token = request.POST['token']
# query con client_id, access token
token = IssuedToken.objects.filter(
access_token=required_token
).first()
session = token.session
if session.client_id != client_id:
return JsonResponse( # pragma: no cover
error = "invalid_client",
error_description = "Client not recognized"
)
active = token and not token.is_revoked and not token.expired
exp = token.expires
sub = token.session.client_id
issuer = self.get_issuer()
iss = issuer.sub
authz_request = session.authz_request
scope = authz_request["scope"]
response = {
"active": active,
"exp": exp,
"sub" : sub,
"iss": iss,
"client_id": client_id,
"aud": [client_id],
"scope": scope
}
return JsonResponse(response)
@@ -0,0 +1,105 @@
import logging
from djagger.decorators import schema
from django.http import (
HttpResponse,
JsonResponse
)
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from spid_cie_oidc.provider.exceptions import ValidationException
from spid_cie_oidc.provider.models import IssuedToken
from spid_cie_oidc.provider.settings import (
OIDCFED_DEFAULT_PROVIDER_PROFILE,
OIDCFED_PROVIDER_PROFILES
)
from . import OpBase
logger = logging.getLogger(__name__)
schema_profile = OIDCFED_PROVIDER_PROFILES[OIDCFED_DEFAULT_PROVIDER_PROFILE]
@schema(
methods=['POST'],
post_request_schema = {
"application/x-www-form-urlencoded": schema_profile["revocation_request"],
"application/json": schema_profile["revocation_request"],
},
post_response_schema = {
"200": {},
"400": schema_profile["revocation_response"]
},
tags = ['Provider']
)
@method_decorator(csrf_exempt, name="dispatch")
class RevocationEndpoint(OpBase, View):
def post(self, request, *args, **kwargs):
try:
self.validate_json_schema(
request.POST.dict(),
"revocation_request",
"Revocation request object validation failed "
)
self.check_client_assertion(
request.POST['client_id'],
request.POST['client_assertion']
)
except ValidationException: # pragma: no cover
return JsonResponse(
{
"error": "invalid_request",
"error_description": "Revocation request object validation failed",
},
status = 400
)
except Exception:
return JsonResponse(
{
"error": "invalid_request",
"error_description": "Validation of client assertion failed",
},
status = 400
)
access_token = request.POST.get('token', None)
if not access_token: # pragma: no cover
return JsonResponse(
{
"error": "invalid_request",
"error_description": "The request does not include Access Token",
},
status = 400
)
token = IssuedToken.objects.filter(
access_token = access_token,
revoked = False
).first()
if not token or token.expired:
return JsonResponse(
{
"error": "invalid_grant",
"error_description": "Access Token not found or expired",
},
status = 400
)
if token.is_revoked: # pragma: no cover
return JsonResponse(
{
"error": "invalid_grant",
"error_description": "Access Token revoked",
},
status = 400
)
token.session.revoke()
return HttpResponse()
@@ -0,0 +1,228 @@
import base64
import hashlib
import logging
from djagger.decorators import schema
from djagger.utils import schema_set_examples
from django.conf import settings
from django.http import (
HttpResponseBadRequest,
HttpResponseForbidden,
JsonResponse
)
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from pydantic import BaseModel
from spid_cie_oidc.entity.jwtse import unpad_jwt_payload
from spid_cie_oidc.provider.exceptions import ValidationException
from spid_cie_oidc.provider.models import IssuedToken, OidcSession
from spid_cie_oidc.provider.settings import (
OIDCFED_DEFAULT_PROVIDER_PROFILE,
OIDCFED_PROVIDER_PROFILES,
OIDCFED_PROVIDER_MAX_CONSENT_TIMEFRAME
)
from spid_cie_oidc.entity.utils import iat_now
from . import OpBase
logger = logging.getLogger(__name__)
schema_profile = OIDCFED_PROVIDER_PROFILES[OIDCFED_DEFAULT_PROVIDER_PROFILE]
@schema(
methods=['GET', 'POST'],
post_request_schema={
"authn_request": schema_profile["authorization_code"],
"refresh_request": schema_profile["refresh_token"],
},
post_response_schema={
"200": schema_profile["authorization_code_response"],
# TODO
# "200": schema_profile["refresh_token_response"],
"400": schema_profile["token_error_response"],
},
get_response_schema={
"400": BaseModel
},
tags=['Provider']
)
@method_decorator(csrf_exempt, name="dispatch")
class TokenEndpoint(OpBase, View):
"""
Request content type is 'application/x-www-form-urlencoded'
"""
schema = {}
schema["authn_request"] = schema_set_examples({}, schema_profile["authorization_code"])
schema["refresh_request"] = schema_set_examples({}, schema_profile["refresh_token"])
def get(self, request, *args, **kwargs):
return HttpResponseBadRequest()
def grant_auth_code(self, request, *args, **kwargs):
"""
Token request for an authorization code grant
"""
# PKCE check - based on authz.authz_request["code_challenge_method"] == S256
code_challenge = hashlib.sha256(request.POST["code_verifier"].encode()).digest()
code_challenge_b64 = base64.urlsafe_b64encode(code_challenge).decode("utf-8")
code_challenge_unpad = code_challenge_b64.replace("=", "")
if code_challenge_unpad != self.authz.authz_request["code_challenge"]:
logger.warning(
f"PCKE validation failed. Produced code challenge [{code_challenge_unpad}]"
f" if different from which sent by RP [{self.authz.authz_request['code_challenge']}]"
)
return HttpResponseForbidden()
#
issued_token = IssuedToken.objects.filter(
session=self.authz,
revoked=False
).first()
jwk_at = unpad_jwt_payload(issued_token.access_token)
expires_in = self.get_expires_in(jwk_at['iat'], jwk_at['exp'])
iss_token_data = dict( # nosec B106
access_token=issued_token.access_token,
id_token=issued_token.id_token,
token_type="Bearer", # nosec B106
expires_in=expires_in,
# TODO: remove unsupported scope
scope=self.authz.authz_request["scope"],
)
if issued_token.refresh_token:
iss_token_data['refresh_token'] = issued_token.refresh_token
return JsonResponse(iss_token_data)
def is_token_renewable(self, session) -> bool:
provider = getattr(settings, "OIDCFED_PROVIDER_PROFILE")
if provider == "cie":
issuedToken = IssuedToken.objects.filter(
session=session
).first()
id_token = unpad_jwt_payload(issuedToken.id_token)
consent_expiration = id_token['iat'] + OIDCFED_PROVIDER_MAX_CONSENT_TIMEFRAME
delta = consent_expiration - iat_now()
if delta > 0:
return True
return False
elif provider == "spid":
# TODO: check also ACR
issuedToken = IssuedToken.objects.filter(
session=session
)
return (
(issuedToken.count() - 1) < getattr(
settings, "OIDCFED_PROVIDER_MAX_REFRESH", 1
)
)
def grant_refresh_token(self, request, *args, **kwargs):
"""
client_id=https://rp.cie.it&
client_assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlNQSUQiLCJhZG1pbiI6dHJ1ZX0.LVyRDPVJm0S9q7oiXcYVIIqGWY0wWQlqxvFGYswL...&
client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&
refresh_token=8xLOxBtZp8 &
grant_type=refresh_token
"""
# 1. get the IssuedToken refresh one, revoked = None
# 2. create a new instance of issuedtoken linked to the same sessions and revoke the older
# 3. response with a new refresh, access and id_token
issued_token = IssuedToken.objects.filter(
refresh_token=request.POST['refresh_token'],
revoked=False
).first()
if not issued_token:
return JsonResponse(
{
"error": "invalid_request",
"error_description": "Refresh token not found",
},
status=400
)
session = issued_token.session
if not self.is_token_renewable(session): # pragma: no cover
return JsonResponse(
{
"error": "invalid_request",
"error_description": "Refresh Token can no longer be updated",
}, status=400
)
iss_token_data = self.get_iss_token_data(session, self.get_issuer())
IssuedToken.objects.create(**iss_token_data)
issued_token.revoked = True
issued_token.save()
jwk_at = unpad_jwt_payload(iss_token_data['access_token'])
expires_in = self.get_expires_in(jwk_at['iat'], jwk_at['exp'])
data = dict( # nosec B106
access_token=iss_token_data['access_token'],
id_token=iss_token_data['id_token'],
refresh_token=iss_token_data['refresh_token'],
token_type="Bearer", # nosec B106
expires_in=expires_in
)
return JsonResponse(data)
def post(self, request, *args, **kwargs):
logger.debug(f"{request.headers}: {request.POST}")
try:
self.validate_json_schema(
request.POST.dict(),
request.POST["grant_type"],
"Token request object validation failed "
)
except ValidationException:
return JsonResponse(
{
"error": "invalid_request",
"error_description": "Token request object validation failed",
},
status=400
)
self.commons = self.get_jwt_common_data()
self.issuer = self.get_issuer()
# check client_assertion and client ownership
try:
self.check_client_assertion(
request.POST['client_id'],
request.POST['client_assertion']
)
except Exception as e: # pragma: no cover
logger.warning(
"Client authentication failed for "
f"{request.POST.get('client_id', 'unknown')}: {e}"
)
return JsonResponse(
# TODO: error message here
{
'error': "unauthorized_client",
'error_description': ""
}, status=403
)
if request.POST.get("grant_type") == 'authorization_code':
self.authz = OidcSession.objects.filter(
auth_code=request.POST["code"],
revoked=False
).first()
if not self.authz:
return HttpResponseBadRequest()
return self.grant_auth_code(request)
elif request.POST.get("grant_type") == 'refresh_token':
return self.grant_refresh_token(request)
else:
# Token exchange? :-)
raise NotImplementedError()
@@ -0,0 +1,106 @@
import logging
from django.http import (
HttpResponse,
HttpResponseForbidden,
)
from djagger.decorators import schema
from django.utils import timezone
from django.views import View
from spid_cie_oidc.entity.jwtse import (
create_jws,
create_jwe,
unpad_jwt_payload
)
from spid_cie_oidc.entity.models import (
TrustChain
)
from spid_cie_oidc.entity.utils import get_jwks, get_key, KeyUsage
from spid_cie_oidc.provider.models import IssuedToken
from . import OpBase
logger = logging.getLogger(__name__)
@schema(
summary="OIDC Provider UserInfo endpoint",
methods=['GET'],
# TODO
# security=[
# dict(
# description = "",
# name = "",
# bearerFormat = "JWT",
# )
# ],
external_docs = {
"alt_text": "AgID SPID OIDC Guidelines",
"url": (
"https://www.agid.gov.it/it/agenzia/stampa-e-comunicazione/"
"notizie/2021/12/06/openid-connect-spid-adottate-linee-guida"
),
},
tags = ['Provider']
)
class UserInfoEndpoint(OpBase, View):
def get(self, request, *args, **kwargs):
"""
Userinfo endpoint just needs a HTTP GET with the Access Token
in a Authorization Http Header like this:
Authorization: Bearer $JWT
"""
ah = request.headers.get("Authorization", None)
if not ah or "Bearer " not in ah:
return HttpResponseForbidden()
bearer = ah.split("Bearer ")[1]
token = IssuedToken.objects.filter(
access_token=bearer,
revoked=False,
session__revoked=False,
expires__gte=timezone.localtime(),
).first()
if not token:
return HttpResponseForbidden()
rp_tc = TrustChain.objects.filter(
sub=token.session.client_id,
metadata__openid_relying_party__isnull=False,
is_active=True
).first()
if not rp_tc:
return HttpResponseForbidden()
issuer = self.get_issuer()
access_token_data = unpad_jwt_payload(token.access_token)
# TODO: user claims
jwt = {"sub": access_token_data["sub"]}
for claim in (
token.session.authz_request.get(
"claims", {}
).get("userinfo", {}).keys()
):
if claim in token.session.user.attributes:
jwt[claim] = token.session.user.attributes[claim]
# sign the data
key = get_key(issuer.jwks_core, KeyUsage.signature)
jws = create_jws(jwt, key)
# encrypt the data
client_jwks = get_jwks(
rp_tc.metadata['openid_relying_party'],
federation_jwks = rp_tc.jwks
)
client_jwk = get_key(client_jwks, KeyUsage.encryption)
jwe = create_jwe(
jws,
client_jwk,
cty="JWT"
)
return HttpResponse(jwe, content_type="application/jwt")