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,47 @@
from django.contrib import admin
from .models import (
FederationDescendant,
FederationDescendantContact,
FederationEntityProfile,
FederationEntityAssignedProfile
)
class FederationDescendantContactAdminInline(admin.TabularInline):
model = FederationDescendantContact
extra = 0
readonly_fields = ("created", "modified")
raw_id_fields = ("entity",)
@admin.register(FederationDescendant)
class FederationDescendantAdmin(admin.ModelAdmin):
list_display = ("sub", "name", "type", "status", "is_active", "created")
list_filter = ("type", "created", "modified", "is_active")
search_fields = ("sub",)
readonly_fields = (
"created",
"modified",
"entity_statement_preview",
)
inlines = (
FederationDescendantContactAdminInline,
)
@admin.register(FederationEntityProfile)
class FederationEntityProfileAdmin(admin.ModelAdmin):
list_display = ("name", "profile_id")
list_filter = ("created", "modified")
search_fields = ("name", "profile_id",)
readonly_fields = ("trust_mark_template_as_json",)
@admin.register(FederationEntityAssignedProfile)
class FederationEntityAssignedProfileAdmin(admin.ModelAdmin):
list_display = ("descendant", "profile", "issuer", "created")
raw_id_fields = ("descendant", "profile", "issuer")
list_filter = ("created", "modified")
search_fields = ("descendant__sub", "descendant__name", "profile")
readonly_fields = ("trust_mark_as_json", "trust_mark", "created", "modified")
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class AuthorityConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "spid_cie_oidc.authority"
verbose_name = "SPID/CIE OIDC Federation Authority"
label = "spid_cie_oidc_authority"
@@ -0,0 +1,310 @@
# Generated by Django 4.0.2 on 2022-02-13 09:06
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import spid_cie_oidc.authority.models
class Migration(migrations.Migration):
initial = True
dependencies = [
("spid_cie_oidc_entity", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="FederationDescendant",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
("modified", models.DateTimeField(auto_now=True)),
(
"uid",
models.CharField(
default=spid_cie_oidc.authority.models.FederationDescendant.def_uid,
help_text="an unique code that identifies this entry. For italian public service it may be the IPA code.",
max_length=1024,
unique=True,
),
),
(
"name",
models.CharField(
help_text="human readable name of this entity. It may be a unit or organization name",
max_length=33,
),
),
(
"sub",
models.URLField(
help_text="URL that identifies this Entity in the Federation.",
max_length=255,
unique=True,
validators=[
spid_cie_oidc.authority.validators.validate_entity_configuration
],
),
),
(
"type",
models.CharField(
blank=True,
choices=[
("federation_entity", "federation_entity"),
("openid_relying_party", "openid_relying_party"),
("openid_provider", "openid_provider"),
("oauth_resource", "oauth_resource"),
],
default="openid_relying_party",
help_text="OpenID Connect Federation entity type",
max_length=33,
),
),
(
"metadata_policy",
models.JSONField(
blank=True,
default=dict,
help_text="if present overloads the DEFAULT policy",
),
),
(
"constraints",
models.JSONField(
blank=True,
default=dict,
help_text="if present overloads the DEFAULT policy",
),
),
(
"extended_claims",
models.JSONField(
blank=True,
default=dict,
help_text="a dictionary containing any other claim like: jti, crti, policy_language_crit and any other extension",
),
),
(
"status",
models.CharField(
choices=[
("unreachable", "unreachable"),
("valid", "valid"),
("signature_failed", "signature_failed"),
("not_valid", "not_valid"),
("unknown", "unknown"),
("expired", "expired"),
],
default=False,
help_text="Its entity configuration is periodically fetched and validated.",
max_length=33,
),
),
(
"status_log",
models.JSONField(blank=True, default=dict, help_text="status log"),
),
(
"is_active",
models.BooleanField(
default=False, help_text="If this entity is active. "
),
),
(
"registrant",
models.ManyToManyField(
blank=True,
help_text="Logged in users can modify only sub, contacts and jwks attributes, if they're owner of this entry. ",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Federation Entity Descendant",
"verbose_name_plural": "Federation Entity Descendants",
},
),
migrations.CreateModel(
name="FederationEntityProfile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
("modified", models.DateTimeField(auto_now=True)),
("name", models.CharField(help_text="Profile name. ", max_length=33)),
(
"profile_category",
models.CharField(
choices=[
("federation_entity", "federation_entity"),
("openid_relying_party", "openid_relying_party"),
("openid_provider", "openid_provider"),
("oauth_resource", "oauth_resource"),
],
help_text="Profile id. It SHOULD be a URL but feel free to put whatever",
max_length=64,
),
),
(
"profile_id",
models.CharField(
help_text="Profile id. It SHOULD be a URL but feel free to put whatever",
max_length=1024,
unique=True,
),
),
(
"trust_mark_template",
models.JSONField(
blank=True,
default=dict,
help_text="trust marks template for this profile",
),
),
],
options={
"verbose_name": "Federation Entity Profile",
"verbose_name_plural": "Federation Entity Profiles",
},
),
migrations.CreateModel(
name="FederationEntityAssignedProfile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
("modified", models.DateTimeField(auto_now=True)),
(
"descendant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_authority.federationdescendant",
),
),
(
"issuer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_entity.federationentityconfiguration",
),
),
(
"profile",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_authority.federationentityprofile",
),
),
],
options={
"verbose_name": "Federation Entity Descendant Assigned Profile",
"verbose_name_plural": "Federation Descendant Assigned Profiles",
},
),
migrations.CreateModel(
name="FederationDescendantJwk",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
("modified", models.DateTimeField(auto_now=True)),
(
"descendant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_authority.federationdescendant",
),
),
(
"jwk",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_entity.publicjwk",
),
),
],
options={
"verbose_name": "Federation Descendant Public JWK",
"verbose_name_plural": "Federation Descendant Public JWKs",
},
),
migrations.CreateModel(
name="FederationDescendantContact",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
("modified", models.DateTimeField(auto_now=True)),
(
"contact",
models.CharField(
help_text="any kind of contact type, usually an email.",
max_length=255,
),
),
(
"type",
models.CharField(
choices=[
("email", "email"),
("telephone", "telephone"),
("other", "other"),
],
max_length=255,
),
),
(
"entity",
models.ForeignKey(
help_text="Entity for which this contac is related",
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_authority.federationdescendant",
),
),
],
options={
"verbose_name": "Federation Entity Contact",
"verbose_name_plural": "Federation Entity Contacts",
},
),
]
@@ -0,0 +1,20 @@
# Generated by Django 4.0.2 on 2022-02-14 22:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_authority", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="federationentityprofile",
name="trust_mark_template",
field=models.JSONField(
default=dict, help_text="trust marks template for this profile"
),
),
]
@@ -0,0 +1,50 @@
# Generated by Django 4.0.2 on 2022-02-20 22:18
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
(
"spid_cie_oidc_authority",
"0002_alter_federationentityprofile_trust_mark_template",
),
]
operations = [
migrations.AlterField(
model_name="federationdescendantcontact",
name="entity",
field=models.ForeignKey(
help_text="Entity for which this contac is related",
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_authority.federationdescendant",
),
),
migrations.AlterField(
model_name="federationdescendantjwk",
name="descendant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_authority.federationdescendant",
),
),
migrations.AlterField(
model_name="federationentityassignedprofile",
name="descendant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_authority.federationdescendant",
),
),
migrations.AlterField(
model_name="federationentityassignedprofile",
name="profile",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_authority.federationentityprofile",
),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 4.0.2 on 2022-03-06 22:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_authority', '0003_alter_federationdescendantcontact_entity_and_more'),
]
operations = [
migrations.AddField(
model_name='federationdescendant',
name='jwks',
field=models.JSONField(default=list, help_text='a list of public keys'),
preserve_default=False,
),
migrations.DeleteModel(
name='FederationDescendantJwk',
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-03-06 23:11
from django.db import migrations, models
import spid_cie_oidc.entity.validators
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_authority', '0004_federationdescendant_jwks_and_more'),
]
operations = [
migrations.AlterField(
model_name='federationdescendant',
name='jwks',
field=models.JSONField(default=list, help_text='a list of public keys', validators=[spid_cie_oidc.entity.validators.validate_public_jwks]),
),
]
@@ -0,0 +1,32 @@
# Generated by Django 4.0.3 on 2022-03-20 10:50
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import spid_cie_oidc.authority.utils
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('spid_cie_oidc_authority', '0005_alter_federationdescendant_jwks'),
]
operations = [
migrations.CreateModel(
name='StaffToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True)),
('modified', models.DateTimeField(auto_now=True)),
('token', models.CharField(default=spid_cie_oidc.authority.utils.random_token, help_text='any kind of contact type, usually an email.', max_length=255)),
('is_active', models.BooleanField(default=True)),
('user', models.ForeignKey(help_text='The user responsible of thi token', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Staff Token',
'verbose_name_plural': 'Staff Tokens',
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.0.3 on 2022-03-20 10:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_authority', '0006_stafftoken'),
]
operations = [
migrations.AddField(
model_name='stafftoken',
name='expire_at',
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-03-28 12:04
from django.db import migrations, models
import spid_cie_oidc.authority.utils
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_authority', '0007_stafftoken_expire_at'),
]
operations = [
migrations.AlterField(
model_name='stafftoken',
name='token',
field=models.CharField(default=spid_cie_oidc.authority.utils.random_token, help_text='it will be generated automatically.', max_length=255),
),
]
@@ -0,0 +1,16 @@
# Generated by Django 4.0.5 on 2022-10-18 09:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_authority', '0008_alter_stafftoken_token'),
]
operations = [
migrations.DeleteModel(
name='StaffToken',
),
]
@@ -0,0 +1,30 @@
# Generated by Django 4.2.1 on 2023-07-09 15:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_authority", "0009_delete_stafftoken"),
]
operations = [
migrations.AddField(
model_name="federationdescendant",
name="metadata",
field=models.JSONField(
blank=True,
default=dict,
help_text="if present it overloads the descendants metadata, in the defined parts. This will be applied to the subordinate only.",
),
),
migrations.AlterField(
model_name="federationdescendant",
name="metadata_policy",
field=models.JSONField(
blank=True,
default=dict,
help_text="if present it overloads the DEFAULT policy. This will be applied recursively to all the descendants.",
),
),
]
@@ -0,0 +1,54 @@
# Generated by Django 4.2.1 on 2023-07-09 15:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_authority", "0010_federationdescendant_metadata_and_more"),
]
operations = [
migrations.AddField(
model_name="federationdescendant",
name="issue_x509",
field=models.BooleanField(
default=False,
help_text="Issues x509 certificates in the entity statement.",
),
),
migrations.AlterField(
model_name="federationdescendant",
name="type",
field=models.CharField(
blank=True,
choices=[
("federation_entity", "federation_entity"),
("openid_relying_party", "openid_relying_party"),
("openid_provider", "openid_provider"),
("oauth_resource", "oauth_resource"),
("wallet_provider", "wallet_provider"),
("wallet_relying_party", "wallet_relying_party"),
],
default="openid_relying_party",
help_text="OpenID Connect Federation entity type",
max_length=33,
),
),
migrations.AlterField(
model_name="federationentityprofile",
name="profile_category",
field=models.CharField(
choices=[
("federation_entity", "federation_entity"),
("openid_relying_party", "openid_relying_party"),
("openid_provider", "openid_provider"),
("oauth_resource", "oauth_resource"),
("wallet_provider", "wallet_provider"),
("wallet_relying_party", "wallet_relying_party"),
],
help_text="Profile id. It SHOULD be a URL but feel free to put whatever",
max_length=64,
),
),
]
@@ -0,0 +1,16 @@
# Generated by Django 4.2.1 on 2023-07-13 04:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_authority", "0011_federationdescendant_issue_x509_and_more"),
]
operations = [
migrations.RemoveField(
model_name="federationdescendant",
name="issue_x509",
),
]
@@ -0,0 +1,48 @@
# Generated by Django 4.2.7 on 2023-11-14 14:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_authority", "0012_remove_federationdescendant_issue_x509"),
]
operations = [
migrations.AlterField(
model_name="federationdescendant",
name="type",
field=models.CharField(
blank=True,
choices=[
("federation_entity", "federation_entity"),
("openid_relying_party", "openid_relying_party"),
("openid_provider", "openid_provider"),
("openid_credential_issuer", "openid_credential_issuer"),
("oauth_resource", "oauth_resource"),
("wallet_provider", "wallet_provider"),
("wallet_relying_party", "wallet_relying_party"),
],
default="openid_relying_party",
help_text="OpenID Connect Federation entity type",
max_length=33,
),
),
migrations.AlterField(
model_name="federationentityprofile",
name="profile_category",
field=models.CharField(
choices=[
("federation_entity", "federation_entity"),
("openid_relying_party", "openid_relying_party"),
("openid_provider", "openid_provider"),
("openid_credential_issuer", "openid_credential_issuer"),
("oauth_resource", "oauth_resource"),
("wallet_provider", "wallet_provider"),
("wallet_relying_party", "wallet_relying_party"),
],
help_text="Profile id. It SHOULD be a URL but feel free to put whatever",
max_length=64,
),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.0.5 on 2026-03-23 15:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_authority', '0013_alter_federationdescendant_type_and_more'),
]
operations = [
migrations.AlterField(
model_name='federationdescendant',
name='type',
field=models.CharField(blank=True, choices=[('federation_entity', 'federation_entity'), ('openid_relying_party', 'openid_relying_party'), ('openid_provider', 'openid_provider'), ('openid_credential_issuer', 'openid_credential_issuer'), ('oauth_resource', 'oauth_resource'), ('wallet_provider', 'wallet_provider'), ('openid_credential_verifier', 'openid_credential_verifier')], default='openid_relying_party', help_text='OpenID Connect Federation entity type', max_length=33),
),
migrations.AlterField(
model_name='federationentityprofile',
name='profile_category',
field=models.CharField(choices=[('federation_entity', 'federation_entity'), ('openid_relying_party', 'openid_relying_party'), ('openid_provider', 'openid_provider'), ('openid_credential_issuer', 'openid_credential_issuer'), ('oauth_resource', 'oauth_resource'), ('wallet_provider', 'wallet_provider'), ('openid_credential_verifier', 'openid_credential_verifier')], help_text='Profile id. It SHOULD be a URL but feel free to put whatever', max_length=64),
),
]
@@ -0,0 +1,353 @@
from copy import deepcopy
from cryptojwt.jwk.jwk import key_from_jwk_dict
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
# from django.db.models.signals import post_save
from django.utils.translation import gettext as _
from spid_cie_oidc.entity.abstract_models import TimeStampedModel
from spid_cie_oidc.entity.models import (
ENTITY_TYPES,
ENTITY_STATUS,
FederationEntityConfiguration
)
from spid_cie_oidc.entity.jwtse import create_jws
from spid_cie_oidc.entity.jwk_x5c import update_jwks_with_x5c
from spid_cie_oidc.entity.validators import validate_public_jwks
from spid_cie_oidc.entity.utils import iat_now, exp_from_now
from spid_cie_oidc.entity.settings import FEDERATION_DEFAULT_EXP
from spid_cie_oidc.entity.models import get_first_self_trust_anchor
from . settings import FEDERATION_DEFAULT_POLICY
from .validators import validate_entity_configuration
import json
import logging
import uuid
logger = logging.getLogger(__name__)
class FederationEntityProfile(TimeStampedModel):
"""
Federation OnBoarding profile.
It optionally defines trust marks templates
"""
name = models.CharField(max_length=33, help_text=_("Profile name. "))
profile_category = models.CharField(
max_length=64,
help_text=_("Profile id. It SHOULD be a URL but feel free to put whatever"),
choices=[(i, i) for i in ENTITY_TYPES],
)
profile_id = models.CharField(
max_length=1024,
help_text=_("Profile id. It SHOULD be a URL but feel free to put whatever"),
unique=True,
)
trust_mark_template = models.JSONField(
help_text=_("trust marks template for this profile"), default=dict
)
class Meta:
verbose_name = "Federation Entity Profile"
verbose_name_plural = "Federation Entity Profiles"
@property
def trust_mark_template_as_json(self):
return json.dumps(self.trust_mark_template)
def __str__(self):
return f"{self.name} {self.profile_id}"
class FederationDescendant(TimeStampedModel):
"""
Federation OnBoarding entries.
"""
def def_uid():
return f"autouid-{uuid.uuid4()}"
uid = models.CharField(
max_length=1024,
default=def_uid,
unique=True,
help_text=_(
"an unique code that identifies this entry. "
"For italian public service it may be the IPA code."
),
)
name = models.CharField(
max_length=33,
help_text=_(
"human readable name of this entity. "
"It may be a unit or organization name"
),
)
sub = models.URLField(
max_length=255,
blank=False,
null=False,
unique=True,
help_text=_("URL that identifies this Entity in the Federation."),
validators=[validate_entity_configuration],
)
type = models.CharField(
max_length=33,
blank=True,
default="openid_relying_party",
choices=[(i, i) for i in ENTITY_TYPES],
help_text=_("OpenID Connect Federation entity type"),
)
registrant = models.ManyToManyField(
get_user_model(),
help_text=_(
"Logged in users can modify only sub, contacts and jwks "
"attributes, if they're owner of this entry. "
),
blank=True,
)
jwks = models.JSONField(
blank=False,
null=False,
help_text=_("a list of public keys"),
default=list,
validators = [validate_public_jwks]
)
metadata_policy = models.JSONField(
blank=True, help_text=_(
"if present it overloads the DEFAULT policy. This will be applied "
"recursively to all the descendants."
), default=dict
)
metadata = models.JSONField(
blank=True, help_text=_(
"if present it overloads the descendants metadata, "
"in the defined parts. This will be applied to the subordinate only."
), default=dict
)
constraints = models.JSONField(
blank=True, help_text=_("if present overloads the DEFAULT policy"), default=dict
)
extended_claims = models.JSONField(
blank=True,
help_text=_(
"a dictionary containing any other claim like: "
"jti, crti, policy_language_crit and any other extension"
),
default=dict,
)
status = models.CharField(
max_length=33,
default=False,
help_text=_("Its entity configuration is periodically fetched and validated."),
choices=[(i, i) for i in ENTITY_STATUS.keys()],
)
status_log = models.JSONField(blank=True, help_text=_("status log"), default=dict)
is_active = models.BooleanField(
default=False, help_text=_("If this entity is active. ")
)
class Meta:
verbose_name = "Federation Entity Descendant"
verbose_name_plural = "Federation Entity Descendants"
@property
def trust_marks(self):
#
profiles = FederationEntityAssignedProfile.objects.filter(descendant=self)
return [i.trust_mark for i in profiles]
@property
def trust_marks_as_json(self):
return json.dumps(self.trust_marks)
@property
def entity_profiles(self):
return [
i.profile.profile_category
for i in FederationEntityAssignedProfile.objects.filter(descendant=self)
]
def entity_statement_as_dict(self, iss: str = None, aud: list = None) -> dict:
ta = get_first_self_trust_anchor(iss)
private_key = key_from_jwk_dict(ta.jwks_fed[0])
if hasattr(settings, "X509_COMMON_NAME"):
self.jwks = update_jwks_with_x5c(
jwks = self.jwks,
private_key=private_key.private_key(),
subject = self.sub,
path_length = None,
is_ca_or_int = True
)
policies = {
k: FEDERATION_DEFAULT_POLICY.get(k, {}) for k in self.entity_profiles
}
# apply custom policies if defined
policies.update(self.metadata_policy)
data = {
"exp": exp_from_now(minutes=FEDERATION_DEFAULT_EXP),
"iat": iat_now(),
"iss": ta.sub,
"sub": self.sub,
"jwks": {"keys": self.jwks}
}
if policies:
data["metadata_policy"] = policies
# OpenID Federation 8.1.2: source_endpoint enables optimized refresh
if ta.fetch_endpoint:
data["source_endpoint"] = ta.fetch_endpoint
if aud:
data["aud"] = [aud] if isinstance(aud, str) else aud
# add contacts
contacts = FederationDescendantContact.objects.filter(entity=self).values_list(
"contact", flat=True
)
if contacts:
for k, v in data.get("metadata_policy", {}).items():
if data["metadata_policy"][k].get("contacts"):
data["metadata_policy"][k]["contacts"].update(
{"add": [i for i in contacts]}
)
else:
data["metadata_policy"][k]["contacts"] = {
"add": [i for i in contacts]
}
# include active trust marks
tm = self.trust_marks
if tm:
data["trust_marks"] = tm
return data
def entity_statement_as_json(self, iss: str = None, aud: list = None) -> str:
return json.dumps(self.entity_statement_as_dict(iss, aud))
def entity_statement_preview(self):
return self.entity_statement_as_json()
def entity_statement_as_jws(self, iss: str = None, aud: list = None) -> str:
issuer = get_first_self_trust_anchor(iss)
return create_jws(
self.entity_statement_as_dict(iss, aud),
issuer.jwks_fed[0],
alg=issuer.default_signature_alg,
typ="entity-statement+jwt"
)
def __str__(self):
return "{} [{} and {}]".format(
self.sub, self.status, "active" if self.is_active else "--"
)
class FederationEntityAssignedProfile(TimeStampedModel):
descendant = models.ForeignKey(FederationDescendant, on_delete=models.CASCADE)
profile = models.ForeignKey(FederationEntityProfile, on_delete=models.CASCADE)
issuer = models.ForeignKey(FederationEntityConfiguration, on_delete=models.CASCADE)
class Meta:
verbose_name = "Federation Entity Descendant Assigned Profile"
verbose_name_plural = "Federation Descendant Assigned Profiles"
@property
def trust_mark_as_dict(self) -> dict:
data = deepcopy(self.profile.trust_mark_template)
data["sub"] = self.descendant.sub
data["iss"] = self.issuer.sub
data["iat"] = iat_now()
# Draft 48: trust_mark_type; retrocompat: also set trust_mark_id
data["trust_mark_type"] = self.profile.profile_id
data.setdefault("trust_mark_id", self.profile.profile_id)
return data
@property
def trust_mark_as_json(self):
return json.dumps(self.trust_mark_as_dict)
@property
def trust_mark_as_jws(self):
return create_jws(
self.trust_mark_as_dict,
self.issuer.jwks_fed[0],
alg=self.issuer.default_signature_alg,
typ="trust-mark+jwt"
)
@property
def trust_mark(self):
# Draft 48: trust_mark_type; retrocompat: also trust_mark_id and id
return {
"trust_mark_type": self.profile.profile_id,
"trust_mark": self.trust_mark_as_jws,
"trust_mark_id": self.profile.profile_id,
"id": self.profile.profile_id,
}
def __str__(self):
return f"{self.profile} [{self.descendant}]"
class FederationDescendantContact(TimeStampedModel):
"""
Federation OnBoarding entries.
"""
entity = models.ForeignKey(
FederationDescendant,
on_delete=models.CASCADE,
help_text=_("Entity for which this contac is related"),
)
contact = models.CharField(
max_length=255,
blank=False,
null=False,
help_text=_("any kind of contact type, usually an email."),
)
type = models.CharField(
max_length=255,
blank=False,
null=False,
choices=[(i, i) for i in ("email", "telephone", "other")],
)
class Meta:
verbose_name = "Federation Entity Contact"
verbose_name_plural = "Federation Entity Contacts"
def __str__(self):
return f"{self.contact} {self.entity.sub}"
# signal on each save
# def trust_chain_trigger(**kwargs):
# subject = kwargs['instance'].sub
# onboarding uses the first available configuration of federation_entity
# fe = FederationEntityConfiguration.objects.filter(
# is_active=True,
# ).first()
# logger.info(
# f"Receiving trust chain evaluation signal for {subject}"
# )
# return trust_chain_builder(subject)
# post_save.connect(trust_chain_trigger, sender=FederationDescendant)
#
@@ -0,0 +1,39 @@
from typing import List, Optional
from pydantic import BaseModel, HttpUrl, Json
class AdvancedEntityListRequest(BaseModel):
page : Optional[int]
def example(): # pragma: no cover
return AdvancedEntityListRequest(
page= 1,
)
class AdvancedEntityListResponse(BaseModel):
iss: HttpUrl
iat: int
entities: List[Json]
page: int
total_pages: int
total_entries: int
next_page_path: str
prev_page_path: str
def example(): # pragma: no cover
return AdvancedEntityListResponse(
iss= "https://registry.spid.gov.it/",
iat= 1620050972,
entities= [
# "{'https://rp.example.it/spid': {'iat': 1588455866} }",
# "{'https://rp.another.it/spid': {'iat': 1588455866} }"
],
page= 1,
total_pages= 2,
total_entries= 189,
next_page_path= "/federation_adv_list/?page=2",
prev_page_path= ""
)
@@ -0,0 +1,34 @@
from typing import Literal, Optional
from pydantic import BaseModel, HttpUrl, constr
class FetchRequest(BaseModel):
# OpenID Federation 8.1.1: sub REQUIRED; issuer implied by fetch endpoint URL
sub: HttpUrl
def example(): # pragma: no cover
return FetchRequest(sub="http://127.0.0.1:8000/oidc/rp")
class FetchResponse(BaseModel):
jose : constr(regex=r"^[a-zA-Z\_\-0-9]+\.[a-zA-Z\_\-0-9]+\.[a-zA-Z\_\-0-9]+") # noqa: F722
class FedAPIErrorResponse(BaseModel):
operation: Optional[str]
error: Literal[
"invalid_request",
"invalid_client",
"invalid_issuer",
"invalid_subject",
"invalid_trust_anchor",
"invalid_trust_chain",
"invalid_metadata",
"not_found",
"server_error",
"temporarily_unavailable",
"unsupported_parameter",
]
error_description: str
@@ -0,0 +1,20 @@
from typing import List, Literal, Optional, Union
from pydantic import BaseModel, HttpUrl
class ListRequest(BaseModel):
# OpenID Federation 8.2.1: optional filters
entity_type: Optional[
Literal["openid_relying_party", "openid_provider", "oauth_resource", "federation_entity"]
] = None
trust_marked: Optional[bool] = None
trust_mark_type: Optional[Union[HttpUrl, str]] = None
intermediate: Optional[bool] = None
def example(): # pragma: no cover
return ListRequest(entity_type="openid_provider")
class ListResponse(BaseModel):
response: List[HttpUrl]
@@ -0,0 +1,35 @@
from typing import Literal, Optional
from pydantic import BaseModel, HttpUrl, constr
# JWT pattern (three base64url segments) to avoid flake8 F722 on constr(regex=...)
_JWT_PATTERN = r"^[a-zA-Z\_\-0-9]+\.[a-zA-Z\_\-0-9]+\.[a-zA-Z\_\-0-9]+"
class TrustMarkRequest(BaseModel):
# Draft 48: trust_mark (JWT) REQUIRED for new flow
trust_mark: Optional[constr(regex=_JWT_PATTERN)]
# Retrocompat: sub + trust_mark_id / trust_mark_type / id
sub: Optional[HttpUrl]
trust_mark_id: Optional[HttpUrl]
trust_mark_type: Optional[HttpUrl]
id: Optional[HttpUrl]
def example(): # pragma: no cover
return TrustMarkRequest(
trust_mark_type="https://www.spid.gov.it/openid-federation/agreement/op-public/",
sub="http://127.0.0.1:8000/oidc/op",
)
class TrustMarkResponse(BaseModel):
active: bool
# OpenID Federation 8.4.2: Trust Mark Status Response JWT claims
class TrustMarkStatusResponsePayload(BaseModel):
iss: str # Entity Identifier of the issuer (Trust Mark Issuer)
iat: int
trust_mark: str # The Trust Mark JWT this status is about
status: Literal["active", "expired", "revoked", "invalid"]
@@ -0,0 +1,59 @@
from django.conf import settings
from spid_cie_oidc.entity import settings as entity_settings
MAX_ENTRIES_PAGE = getattr(settings, "MAX_ENTRIES_PAGE", 100)
DEFAULT_JWS_ALG = getattr(settings, "DEFAULT_JWS_ALG", entity_settings.DEFAULT_JWS_ALG)
DEFAULT_JWE_ALG = getattr(settings, "DEFAULT_JWE_ALG", entity_settings.DEFAULT_JWE_ALG)
DEFAULT_JWE_ENC = getattr(settings, "DEFAULT_JWE_ENC", entity_settings.DEFAULT_JWE_ENC)
SIGNING_ALG_VALUES_SUPPORTED = getattr(
settings,
"SIGNING_ALG_VALUES_SUPPORTED",
entity_settings.SIGNING_ALG_VALUES_SUPPORTED,
)
ENCRYPTION_ALG_VALUES_SUPPORTED = getattr(
settings,
"ENCRYPTION_ALG_VALUES_SUPPORTED",
entity_settings.ENCRYPTION_ALG_VALUES_SUPPORTED,
)
ENCRYPTION_ENC_SUPPORTED = getattr(
settings, "ENCRYPTION_ENC_SUPPORTED", entity_settings.ENCRYPTION_ENC_SUPPORTED
)
FEDERATION_DEFAULT_POLICY = getattr(
settings, "FEDERATION_DEFAULT_POLICY", {
"openid_provider": {
"subject_types_supported": {"value": ["pairwise"]},
"id_token_signing_alg_values_supported": {
"subset_of": SIGNING_ALG_VALUES_SUPPORTED
},
"userinfo_signing_alg_values_supported": {
"subset_of": SIGNING_ALG_VALUES_SUPPORTED
},
"token_endpoint_auth_methods_supported": {"value": ["private_key_jwt"]},
"userinfo_encryption_alg_values_supported": {
"subset_of": ENCRYPTION_ALG_VALUES_SUPPORTED
},
"userinfo_encryption_enc_values_supported": {
"subset_of": ENCRYPTION_ENC_SUPPORTED
},
"request_object_encryption_alg_values_supported": {
"subset_of": ENCRYPTION_ALG_VALUES_SUPPORTED
},
"request_object_encryption_enc_values_supported": {
"subset_of": ENCRYPTION_ENC_SUPPORTED
},
"request_object_signing_alg_values_supported": {
"subset_of": SIGNING_ALG_VALUES_SUPPORTED
},
},
"openid_relying_party": {
"grant_types": {"subset_of": ["authorization_code", "refresh_token"]},
"scope": {
"superset_of": ["openid"],
"subset_of": ["openid", "offline_access"],
},
},
"federation_entity": {},
"oauth_resource": {},
}
)
@@ -0,0 +1,165 @@
from django.test import Client
from django.urls import reverse
from spid_cie_oidc.authority.models import FederationDescendant
from spid_cie_oidc.entity.models import FederationEntityConfiguration
from spid_cie_oidc.entity.jwtse import unpad_jwt_payload, create_jws
from spid_cie_oidc.entity.exceptions import HttpError
from spid_cie_oidc.entity.tests.settings import ta_conf_data
from .settings import rp_onboarding_data, intermediary_conf, rp_conf
import copy
import logging
logger = logging.getLogger(__name__)
class DummyContent:
def __init__(self, content):
self.content = content.encode()
self.status_code = 200
class EntityResponse:
def __init__(self):
self.status_code = 200
self.req_counter = 0
self.client = Client()
self.result = None
def result_as_it_is(self):
logger.info(f"Response #{self.req_counter}: {self.result.content.decode()}")
self.req_counter += 1
return self.result.content
def result_as_jws(self):
logger.info(
f"Response #{self.req_counter}: "
f"{unpad_jwt_payload(self.result.content.decode())}"
)
self.req_counter += 1
return self.result.content
def trust_anchor_ec(self):
url = reverse("entity_configuration")
res = self.client.get(url, data={"sub": ta_conf_data["sub"]})
return res
def rp_ec(self):
rp = FederationEntityConfiguration.objects.get(sub=rp_onboarding_data["sub"])
res = DummyContent(rp.entity_configuration_as_jws)
return res
def fetch_rp_from_ta(self):
url = reverse("oidcfed_fetch")
res = self.client.get(url, data={"sub": rp_onboarding_data["sub"]})
return res
class EntityResponseNoIntermediate(EntityResponse):
@property
def content(self):
if self.req_counter == 0:
self.result = self.trust_anchor_ec()
elif self.req_counter == 1:
self.result = self.rp_ec()
elif self.req_counter == 2:
self.result = self.fetch_rp_from_ta()
elif self.req_counter > 2:
raise NotImplementedError(
"The mocked resposes seems to be not aligned with the correct flow"
)
return self.result_as_jws()
class EntityResponseNoIntermediateSignedJwksUri(EntityResponse):
@property
def content(self):
if self.req_counter == 0:
self.result = self.trust_anchor_ec()
elif self.req_counter == 1:
self.result = self.rp_ec()
elif self.req_counter == 2:
self.result = self.fetch_rp_from_ta()
elif self.req_counter == 3:
metadata = copy.deepcopy(rp_conf['metadata']['openid_relying_party'])
_jwks = metadata.pop('jwks')
fed_jwks = rp_conf['jwks_fed'][0]
self.result = create_jws(_jwks, fed_jwks)
return self.result.encode()
elif self.req_counter > 3:
raise NotImplementedError(
"The mocked resposes seems to be not aligned with the correct flow"
)
return self.result_as_jws()
class EntityResponseWithIntermediate(EntityResponse):
@property
def content(self):
if self.req_counter == 0:
self.result = self.trust_anchor_ec()
elif self.req_counter == 1:
self.result = self.rp_ec()
elif self.req_counter == 2:
sa = FederationEntityConfiguration.objects.get(sub=intermediary_conf["sub"])
self.result = DummyContent(sa.entity_configuration_as_jws)
elif self.req_counter == 3:
# Subordinate statement: intermediate -> RP (not TA -> RP)
rp_desc = FederationDescendant.objects.get(sub=rp_onboarding_data["sub"])
stmt_jws = rp_desc.entity_statement_as_jws(intermediary_conf["sub"])
self.result = DummyContent(stmt_jws)
elif self.req_counter == 4:
url = reverse("oidcfed_fetch")
self.result = self.client.get(url, data={"sub": intermediary_conf["sub"]})
elif self.req_counter == 5:
url = reverse("entity_configuration")
self.result = self.client.get(url, data={"sub": ta_conf_data["sub"]})
elif self.req_counter > 5:
raise NotImplementedError(
"The mocked resposes seems to be not aligned with the correct flow"
)
if self.result.status_code != 200:
raise HttpError(f"Something went wrong with Http Request: {self.result.__dict__}")
logger.info("-------------------------------------------------")
logger.info("")
return self.result_as_jws()
class EntityResponseWithIntermediateManyHints(EntityResponse):
@property
def content(self):
if self.req_counter == 0:
self.result = self.trust_anchor_ec()
elif self.req_counter == 1:
self.result = self.rp_ec()
elif self.req_counter == 2:
sa = FederationEntityConfiguration.objects.get(sub=intermediary_conf["sub"])
self.result = DummyContent(sa.entity_configuration_as_jws)
elif self.req_counter == 3:
# Second authority hint (that-faulty): invalid response
self.result = DummyContent("crap")
elif self.req_counter == 4:
# Subordinate statement: intermediate -> RP
rp_desc = FederationDescendant.objects.get(sub=rp_onboarding_data["sub"])
stmt_jws = rp_desc.entity_statement_as_jws(intermediary_conf["sub"])
self.result = DummyContent(stmt_jws)
elif self.req_counter == 5:
# TA -> intermediate subordinate statement
url = reverse("oidcfed_fetch")
self.result = self.client.get(url, data={"sub": intermediary_conf["sub"]})
elif self.req_counter > 5:
raise NotImplementedError(
"The mocked resposes seems to be not aligned with the correct flow"
)
if self.result.status_code != 200:
raise HttpError(f"Something went wrong with Http Request: {self.result.__dict__}")
try:
return self.result_as_jws()
except:
return self.result_as_it_is()
@@ -0,0 +1,149 @@
from copy import deepcopy
from spid_cie_oidc.entity.jwks import serialize_key, new_rsa_key
INTERMEDIARY_RSA = new_rsa_key()
INTERMEDIARY_JWK1 = serialize_key(INTERMEDIARY_RSA.priv_key, kind='private')
INTERMEDIARY_JWK1_pub = serialize_key(INTERMEDIARY_RSA.pub_key)
RP_METADATA_JWK1 = {'use': 'sig', 'kty': 'RSA', 'n': 'w8H80eT2zrs2XQ-SApZG9TkuXDuIxANfCVHt4fFqNnOEZaCNWqlTQIo0JiSBE-QmzZ09TYP1BJpESuQf_PUeLRVPfYHsBVk5OYvhT27_nYlV7_1LsFGLxxsIa-hswMMzvW-1_huKLy6Fp0WP0ouUJAHsF_eYVtO1ApRhvlIVd5azM4k7t8Lh8lkCSdF1SfGHfXnXJRb-XensZ0cFSfe2Koq9mD7jpGLXlPpXxj8Ow0g7KYT5kVtWE5ULmNmO7BIN1Hx4HpggbbEGgC9FyjKw4GfFb-csnB-icBPf_60HomjrkFFt6vTjrcqQaHOj-sEjP36N8rMSBiMmiMSPnsHhMQ', 'e': 'AQAB', 'd': 'jEDxjcTZXBbgBV8Bgt7-qfW1FJoHDEFKFxhfMpHQQoETa-jTPhCxOD2MzYM8A-9kKc8tu9r-crTAl1PI42kPnMd283phixd5G5Tv8gSaGdnq-45ka0iRuC7TItUdDiMNb_2YzB4ZLGLNmaIKQJSGqCHEcQuRVyxJtTZwrXaMMOhDqJaWUvUQWF5C7g5O5mOVTkNKw6ujzhqcWa4N3NE-HwcbVW_9st4s1c_ng-DlwLTptaeM5j-LOeZMX1zcVlwYMi5ZkYYY6FHHjYI4nBWDtqhvf-64QaTv8exIjk8PcxHOwhfLTWiHPLk14af7U_pCzkP87WQCBgNfvt3WILQ5DQ', 'p': '75eNHkWaYQMgzVfFwif5uftSxqOhFU6VkxNKdqoRuFxJuVTO-M-vbQc3BwPxms2xrpizU6zGcoPGPvccDi0G040wZh34pWDVABMgGMKXKmeTwj8FuM1DzOVq8DKHmdrhk1gaQbPAP8JVOVYK7uh_lG5wmz3X-En1McMk-E8g8Ic', 'q': '0Sny6DLNtDP1_B9qiyCaMtRqPSAUZ1ohCZRlBT6-IGRR31Kt5S2JcVNDnF5w4dunlDY4nhIBZ0v0VyzWKgDXj6qrFY1pm1iE29gW227YsVRWQU8xWGpBwEu8nxNMr0u0zfe0QEGWU4RvNAsZPRa31HU87Vm7I3NSZ34DZsCZJoc', 'kid': 'HIvo33-Km7n03ZqKDJfWVnlFudsW28YhQZx5eaXtAKA'}
RP_METADATA_JWK1_pub = {'use': 'sig', 'kty': 'RSA', 'n': 'w8H80eT2zrs2XQ-SApZG9TkuXDuIxANfCVHt4fFqNnOEZaCNWqlTQIo0JiSBE-QmzZ09TYP1BJpESuQf_PUeLRVPfYHsBVk5OYvhT27_nYlV7_1LsFGLxxsIa-hswMMzvW-1_huKLy6Fp0WP0ouUJAHsF_eYVtO1ApRhvlIVd5azM4k7t8Lh8lkCSdF1SfGHfXnXJRb-XensZ0cFSfe2Koq9mD7jpGLXlPpXxj8Ow0g7KYT5kVtWE5ULmNmO7BIN1Hx4HpggbbEGgC9FyjKw4GfFb-csnB-icBPf_60HomjrkFFt6vTjrcqQaHOj-sEjP36N8rMSBiMmiMSPnsHhMQ', 'e': 'AQAB', 'kid': 'HIvo33-Km7n03ZqKDJfWVnlFudsW28YhQZx5eaXtAKA'}
RP_METADATA_JWK2 = {'alg': 'RSA-OAEP', 'use': 'enc', 'kty': 'RSA', 'n': 'w8H80eT2zrs2XQ-SApZG9TkuXDuIxANfCVHt4fFqNnOEZaCNWqlTQIo0JiSBE-QmzZ09TYP1BJpESuQf_PUeLRVPfYHsBVk5OYvhT27_nYlV7_1LsFGLxxsIa-hswMMzvW-1_huKLy6Fp0WP0ouUJAHsF_eYVtO1ApRhvlIVd5azM4k7t8Lh8lkCSdF1SfGHfXnXJRb-XensZ0cFSfe2Koq9mD7jpGLXlPpXxj8Ow0g7KYT5kVtWE5ULmNmO7BIN1Hx4HpggbbEGgC9FyjKw4GfFb-csnB-icBPf_60HomjrkFFt6vTjrcqQaHOj-sEjP36N8rMSBiMmiMSPnsHhMQ', 'e': 'AQAB', 'd': 'jEDxjcTZXBbgBV8Bgt7-qfW1FJoHDEFKFxhfMpHQQoETa-jTPhCxOD2MzYM8A-9kKc8tu9r-crTAl1PI42kPnMd283phixd5G5Tv8gSaGdnq-45ka0iRuC7TItUdDiMNb_2YzB4ZLGLNmaIKQJSGqCHEcQuRVyxJtTZwrXaMMOhDqJaWUvUQWF5C7g5O5mOVTkNKw6ujzhqcWa4N3NE-HwcbVW_9st4s1c_ng-DlwLTptaeM5j-LOeZMX1zcVlwYMi5ZkYYY6FHHjYI4nBWDtqhvf-64QaTv8exIjk8PcxHOwhfLTWiHPLk14af7U_pCzkP87WQCBgNfvt3WILQ5DQ', 'p': '75eNHkWaYQMgzVfFwif5uftSxqOhFU6VkxNKdqoRuFxJuVTO-M-vbQc3BwPxms2xrpizU6zGcoPGPvccDi0G040wZh34pWDVABMgGMKXKmeTwj8FuM1DzOVq8DKHmdrhk1gaQbPAP8JVOVYK7uh_lG5wmz3X-En1McMk-E8g8Ic', 'q': '0Sny6DLNtDP1_B9qiyCaMtRqPSAUZ1ohCZRlBT6-IGRR31Kt5S2JcVNDnF5w4dunlDY4nhIBZ0v0VyzWKgDXj6qrFY1pm1iE29gW227YsVRWQU8xWGpBwEu8nxNMr0u0zfe0QEGWU4RvNAsZPRa31HU87Vm7I3NSZ34DZsCZJoc', 'kid': 'ENC-Km7n03ZqKDJfWVnlFudsW28YhQZx5eaXtAKA'}
RP_METADATA_JWK2_pub = {'alg': 'RSA-OAEP', 'use': 'enc', 'kty': 'RSA', 'n': 'w8H80eT2zrs2XQ-SApZG9TkuXDuIxANfCVHt4fFqNnOEZaCNWqlTQIo0JiSBE-QmzZ09TYP1BJpESuQf_PUeLRVPfYHsBVk5OYvhT27_nYlV7_1LsFGLxxsIa-hswMMzvW-1_huKLy6Fp0WP0ouUJAHsF_eYVtO1ApRhvlIVd5azM4k7t8Lh8lkCSdF1SfGHfXnXJRb-XensZ0cFSfe2Koq9mD7jpGLXlPpXxj8Ow0g7KYT5kVtWE5ULmNmO7BIN1Hx4HpggbbEGgC9FyjKw4GfFb-csnB-icBPf_60HomjrkFFt6vTjrcqQaHOj-sEjP36N8rMSBiMmiMSPnsHhMQ', 'e': 'AQAB', 'kid': 'ENC-Km7n03ZqKDJfWVnlFudsW28YhQZx5eaXtAKA'}
rp_onboarding_data = dict(
name="RP Test",
sub="http://rp-test.it/oidc/rp/",
type="openid_relying_party",
metadata_policy={"openid_relying_party": {"scope": {"value": ["openid"]}}},
is_active=True,
jwks = [RP_METADATA_JWK1_pub]
)
rp_conf = {
"sub": rp_onboarding_data["sub"],
"jwks_fed" : [RP_METADATA_JWK1],
"jwks_core" : [RP_METADATA_JWK1, RP_METADATA_JWK2],
"metadata": {
"openid_relying_party": {
"application_type": "web",
"client_registration_types": ["automatic"],
"client_name": "Name of this service called http://rp-test.it/oidc/rp/",
"contacts": ["[email protected]"],
"grant_types": ["refresh_token", "authorization_code"],
"redirect_uris": ["http://rp-test.it/oidc/rp/callback/"],
"response_types": ["code"],
"subject_type": "pairwise",
"client_id": "http://rp-test.it/oidc/rp/",
"jwks": {"keys": [RP_METADATA_JWK1_pub, RP_METADATA_JWK2_pub]},
}
},
"authority_hints": ["http://testserver/"],
"is_active": True,
}
RP_CONF_AS_JSON = {
"iss": rp_conf["sub"],
"sub": rp_conf["sub"],
"jwks": {
"keys": [RP_METADATA_JWK1_pub, RP_METADATA_JWK2_pub]
},
"metadata": rp_conf["metadata"],
"authority_hints":rp_conf["authority_hints"]
}
intermediary_conf = {
"sub": "http://intermediary-test",
"metadata": {
"federation_entity": {
"contacts": ["ops@localhost"],
"federation_fetch_endpoint": "http://intermediary-test/fetch",
"homepage_uri": "http://intermediary-test",
"name": "example Intermediate",
}
},
"trust_marks": [],
"authority_hints": ["http://testserver/"],
"is_active": True,
"jwks_core": [INTERMEDIARY_JWK1],
"jwks_fed": [INTERMEDIARY_JWK1]
}
intermediary_onboarding_data = dict(
name="intermediary-test",
sub="http://intermediary-test",
type="federation_entity",
# metadata_policy = {"openid_relying_party": {"scope": {"value": ["openid"]}}},
is_active=True,
jwks = [INTERMEDIARY_JWK1_pub]
)
TRUST_MARK_PAYLOAD = {
"iss": "$.issuer_sub",
"sub": "$.sub",
"iat": 1579621160,
"trust_mark_id": "https://www.spid.gov.it/certification/rp",
"logo_uri": "https://www.agid.gov.it/themes/custom/agid/logo.svg",
"ref": "https://docs.italia.it/italia/spid/spid-regole-tecniche-oidc/it/stabile/index.html",
}
RP_PROFILE = {
"name": "SPID Public SP",
"profile_category": "openid_relying_party",
"profile_id": "https://www.spid.gov.it/certification/rp",
"trust_mark_template": TRUST_MARK_PAYLOAD,
}
RP_METADATA = {
"openid_relying_party": {
"application_type": "web",
"client_registration_types": ["automatic"],
"client_name": f"Name of this service called {rp_onboarding_data['sub']}",
"contacts": ["[email protected]"],
"grant_types": ["refresh_token", "authorization_code"],
"jwks": {"keys": [RP_METADATA_JWK1_pub]},
"redirect_uris": [f"{rp_onboarding_data['sub']}/callback/"],
"response_types": ["code"],
"subject_type": "pairwise",
}
}
TA_SUB = "http://testserver.it/"
FETCH_REQUEST = {
"sub": rp_conf["sub"],
}
LIST_REQUEST = {
"entity_type": "openid_relying_party",
}
TRUST_MARK = "eyJhbGciOiJSUzI1NiIsImtpZCI6IkZpZll4MDNibm9zRDhtNmdZUUlmTkhOUDljTV9TYW05VGM1bkxsb0lJcmMiLCJ0eXAiOiJ0cnVzdC1tYXJrK2p3dCJ9.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwMDAvIiwic3ViIjoiaHR0cDovLzEyNy4wLjAuMTo4MDAyL29pZGMvb3AvIiwiaWF0IjoxNjQ4NTcxMTk4LCJpZCI6Imh0dHBzOi8vd3d3LnNwaWQuZ292Lml0L2NlcnRpZmljYXRpb24vb3AiLCJtYXJrIjoiaHR0cHM6Ly93d3cuYWdpZC5nb3YuaXQvdGhlbWVzL2N1c3RvbS9hZ2lkL2xvZ28uc3ZnIiwicmVmIjoiaHR0cHM6Ly9kb2NzLml0YWxpYS5pdC9pdGFsaWEvc3BpZC9zcGlkLXJlZ29sZS10ZWNuaWNoZS1vaWRjL2l0L3N0YWJpbGUvaW5kZXguaHRtbCJ9.mN-AMOmcxOKh9w8SIw9T0N4_wsmjrnMsPp_M2k3irErz0ikrRNtOQ0iPXhJ33szXUxZQJc0jlxu3glCliViv34u6FyAUK-WyVHWf2QKHL5oPbZj45Lx6u1ZMTgU9gKEAUl6MdkGtIEU6F0-q87-BKIKTvJmErH3pdrQG7ed5fR2lvFCN7QQ4rHnVYHVMEnlZxe2oMPtRILsSHaa30yPGCYfp36BpZx9rdmrV0bb8sIsRYFin7GIhkzl4FxqEQ0J6i1A4elgP9tVQsOu1Yd4E54t62KdVtTgfI4qfmP15NoPiNZFHLIFIMsUmqHMh5nuq9KWBb2IC1dfHM6AoxCpqtQ"
TRUST_MARK_REQUEST = {
"sub": rp_conf["sub"],
"trust_mark_id" : rp_conf["sub"],
"trust_mark" : TRUST_MARK
}
TRUST_MARK_REQUEST_NO_SUB_ID = deepcopy(TRUST_MARK_REQUEST)
TRUST_MARK_REQUEST_NO_SUB_ID.pop("sub")
TRUST_MARK_REQUEST_NO_SUB_ID.pop("trust_mark_id")
TRUST_MARK_REQUEST_NO_TRUST_MARK = deepcopy(TRUST_MARK_REQUEST)
TRUST_MARK_REQUEST_NO_TRUST_MARK.pop("trust_mark")
TRUST_MARK_REQUEST_TRUST_MARK_NO_SUB = deepcopy(TRUST_MARK_REQUEST)
TRUST_MARK_REQUEST_TRUST_MARK_NO_SUB.pop("sub")
TRUST_MARK_REQUEST_TRUST_MARK_NO_SUB_NO_TRUST_MARK = deepcopy(TRUST_MARK_REQUEST)
TRUST_MARK_REQUEST_TRUST_MARK_NO_SUB_NO_TRUST_MARK.pop("sub")
TRUST_MARK_REQUEST_TRUST_MARK_NO_SUB_NO_TRUST_MARK.pop("trust_mark")
TRUST_MARK_REQUEST_TRUST_MARK_NO_ID_NO_TRUST_MARK = deepcopy(TRUST_MARK_REQUEST)
TRUST_MARK_REQUEST_TRUST_MARK_NO_ID_NO_TRUST_MARK.pop("trust_mark_id")
TRUST_MARK_REQUEST_TRUST_MARK_NO_ID_NO_TRUST_MARK.pop("trust_mark")
ADVANCED_LIST_REQUEST = {
"page": 1,
}
@@ -0,0 +1,385 @@
from django.test import TestCase, Client, override_settings
from django.urls import reverse
from spid_cie_oidc.entity.exceptions import InvalidRequiredTrustMark
from spid_cie_oidc.entity.jwtse import verify_jws, unpad_jwt_head, unpad_jwt_payload
from spid_cie_oidc.entity.models import *
from spid_cie_oidc.entity.trust_chain_operations import (
dumps_statements_from_trust_chain_to_db,
get_or_create_trust_chain,
trust_chain_builder,
)
from spid_cie_oidc.entity.statements import (
EntityConfiguration,
get_entity_configurations,
TrustMark,
)
from spid_cie_oidc.entity.utils import get_jwks
from spid_cie_oidc.entity.tests.settings import *
from spid_cie_oidc.authority.models import *
from spid_cie_oidc.authority.tests.mocked_responses import *
from spid_cie_oidc.authority.tests.settings import *
from unittest.mock import patch
import copy
import datetime
class TrustChainTest(TestCase):
def setUp(self):
self.ta_conf = FederationEntityConfiguration.objects.create(**ta_conf_data)
self.rp_conf = FederationEntityConfiguration.objects.create(**rp_conf)
self.rp_profile = FederationEntityProfile.objects.create(**RP_PROFILE)
self.rp = FederationDescendant.objects.create(**rp_onboarding_data)
self.rp_assigned_profile = FederationEntityAssignedProfile.objects.create(
descendant=self.rp, profile=self.rp_profile, issuer=self.ta_conf
)
def test_fetch_endpoint(self):
self.rp_profile.__str__()
self.rp_profile.trust_mark_template_as_json
url = reverse("oidcfed_fetch")
c = Client()
res = c.get(url, data={"sub": self.rp.sub})
data = verify_jws(res.content.decode(), self.ta_conf.jwks_fed[0])
self.assertEqual(data['source_endpoint'], 'http://testserver//fetch')
self.assertTrue(data["jwks"])
def test_list_endpoint(self):
url = reverse("oidcfed_list")
c = Client()
res = c.get(url, data={})
self.assertTrue(res.json())
self.assertEqual(res.status_code, 200)
@override_settings(HTTP_CLIENT_SYNC=True)
@patch("requests.get", return_value=EntityResponseNoIntermediate())
def test_trust_chain_valid_no_intermediaries(self, mocked):
self.ta_conf.constraints = {"max_path_length": 0}
self.ta_conf.save()
jwt = get_entity_configurations(self.ta_conf.sub)
trust_anchor_ec = EntityConfiguration(jwt[0])
trust_chain = trust_chain_builder(
subject=rp_onboarding_data["sub"],
trust_anchor=trust_anchor_ec
)
self.assertTrue(trust_chain)
self.assertTrue(trust_chain.final_metadata)
for i in trust_chain.tree_of_trust.values():
for ec in i:
self.assertTrue(ec.is_valid)
for ec in trust_chain.trust_path:
self.assertTrue(ec.is_valid)
self.assertTrue(len(trust_chain.trust_path) == 2)
self.assertTrue((len(trust_chain.trust_path) - 2) == trust_chain.max_path_len)
self.assertTrue(isinstance(trust_chain.exp, int))
self.assertTrue(isinstance(trust_chain.exp_datetime, datetime.datetime))
def _create_federation_with_intermediary(self) -> EntityConfiguration:
jwt = get_entity_configurations(self.ta_conf.sub)
trust_anchor_ec = EntityConfiguration(jwt[0])
self.intermediate = FederationEntityConfiguration.objects.create(
**intermediary_conf
)
self.intermediate_desc = FederationDescendant.objects.create(
**intermediary_onboarding_data
)
FederationEntityAssignedProfile.objects.create(
descendant=self.rp, profile=self.rp_profile, issuer=self.intermediate
)
self.rp_conf.authority_hints = [intermediary_conf["sub"]]
self.rp_conf.save()
return trust_anchor_ec
@override_settings(HTTP_CLIENT_SYNC=True)
@patch("requests.get", return_value=EntityResponseWithIntermediate())
def test_trust_chain_valid_with_intermediary(self, mocked):
trust_anchor_ec = self._create_federation_with_intermediary()
trust_chain = trust_chain_builder(
subject=self.rp.sub,
trust_anchor=trust_anchor_ec
)
self.assertTrue(trust_chain)
self.assertTrue(trust_chain.final_metadata)
for i in trust_chain.tree_of_trust.values():
for ec in i:
self.assertTrue(ec.is_valid)
for ec in trust_chain.trust_path:
self.assertTrue(ec.is_valid)
self.assertTrue(len(trust_chain.trust_path) == 3)
self.assertTrue((len(trust_chain.trust_path) - 2) == trust_chain.max_path_len)
tc_ser = trust_chain.serialize()
_p0 = unpad_jwt_payload(tc_ser[0])
_p1 = unpad_jwt_payload(tc_ser[1])
_p2 = unpad_jwt_payload(tc_ser[2])
_p3 = unpad_jwt_payload(tc_ser[3])
# Entity configurations
self.assertEqual(_p0['iss'], _p0['sub'])
self.assertEqual(_p3['iss'], _p3['sub'])
# Entity statements
self.assertNotEqual(_p1['iss'], _p1['sub'])
self.assertNotEqual(_p2['iss'], _p2['sub'])
# Chain consistency (positive)
self.assertEqual(_p0['iss'], _p1['sub'])
self.assertEqual(_p1['iss'], _p2['sub'])
self.assertEqual(_p2['iss'], _p3['sub'])
# Chain consistency (negative)
self.assertNotEqual(_p1['iss'], _p0['sub'])
self.assertNotEqual(_p2['iss'], _p1['sub'])
self.assertNotEqual(_p3['iss'], _p2['sub'])
dumps = dumps_statements_from_trust_chain_to_db(trust_chain)
self.assertTrue(isinstance(dumps, list) and len(dumps) == 5)
self.assertTrue("sub" in dumps[0].get_entity_configuration_as_obj().payload)
stored_trust_chain = TrustChain.objects.create(
sub=trust_chain.subject,
exp=trust_chain.exp_datetime,
chain=trust_chain.serialize(),
jwks = trust_chain.subject_configuration.jwks,
metadata=trust_chain.final_metadata,
parties_involved=[i.sub for i in trust_chain.trust_path],
status="valid",
trust_anchor=FetchedEntityStatement.objects.get(
sub=trust_anchor_ec.sub, iss=trust_anchor_ec.sub
),
is_active=True,
)
@override_settings(HTTP_CLIENT_SYNC=True)
@patch("requests.get", return_value=EntityResponseWithIntermediateManyHints())
def test_trust_chain_valid_with_intermediaries_many_authhints(self, mocked):
trust_anchor_ec = self._create_federation_with_intermediary()
self.rp_conf.authority_hints = [
"http://intermediary-test",
"http://that-faulty",
]
self.rp_conf.save()
trust_chain = trust_chain_builder(
subject=self.rp.sub,
trust_anchor=trust_anchor_ec
)
for ec in trust_chain.trust_path:
self.assertTrue(ec.is_valid)
self.assertTrue(len(trust_chain.trust_path) == 3)
self.assertTrue((len(trust_chain.trust_path) - 2) == trust_chain.max_path_len)
@override_settings(HTTP_CLIENT_SYNC=True)
@patch("requests.get", return_value=EntityResponseWithIntermediate())
def test_trust_chain_valid_with_intermediaries_and_trust_mark_filter(self, mocked):
trust_anchor_ec = self._create_federation_with_intermediary()
# the RP exposes a trust marks in its entity configuration
self.rp_conf.trust_marks = [
FederationEntityAssignedProfile.objects.filter(descendant=self.rp)
.first()
.trust_mark
]
self.rp_conf.save()
trust_chain = trust_chain_builder(
subject=self.rp.sub,
trust_anchor=trust_anchor_ec,
required_trust_marks=["https://www.spid.gov.it/certification/rp"],
)
for ec in trust_chain.trust_path:
self.assertTrue(ec.is_valid)
self.assertTrue(trust_chain.verified_trust_marks)
self.assertTrue(isinstance(trust_chain.verified_trust_marks[0], TrustMark))
self.assertTrue(len(trust_chain.trust_path) == 3)
self.assertTrue((len(trust_chain.trust_path) - 2) == trust_chain.max_path_len)
@override_settings(HTTP_CLIENT_SYNC=True)
@patch("requests.get", return_value=EntityResponseWithIntermediate())
def test_trust_chain_with_missing_trust_mark(self, mocked):
trust_anchor_ec = self._create_federation_with_intermediary()
with self.assertRaises(InvalidRequiredTrustMark):
trust_chain = trust_chain_builder(
subject=self.rp.sub,
trust_anchor=trust_anchor_ec,
required_trust_marks=["https://www.spid.gov.it/certification/rp"],
)
@override_settings(HTTP_CLIENT_SYNC=True)
@patch("requests.get", return_value=EntityResponseNoIntermediate())
def test_trust_chain_valid_helpers(self, mocked):
gctc = get_or_create_trust_chain(
subject=rp_conf["sub"],
trust_anchor=self.ta_conf.sub,
# TODO
# required_trust_marks: list = []
)
self.assertFalse(gctc.is_expired)
self.assertTrue(gctc.is_valid)
@override_settings(HTTP_CLIENT_SYNC=True)
@patch("requests.get", return_value=EntityResponseNoIntermediate())
def test_resolve_endpoint(self, mocked):
gctc = get_or_create_trust_chain(
subject=rp_conf["sub"],
trust_anchor=self.ta_conf.sub,
# TODO
# required_trust_marks: list = []
)
url = reverse("oidcfed_resolve")
c = Client()
res = c.get(url, data={
"sub": self.rp.sub,
"trust_anchor": self.ta_conf.sub
}
)
self.assertTrue(res.status_code == 200)
verify_jws(res.content.decode(), self.ta_conf.jwks_fed[0])
def test_trust_mark_status_endpoint(self):
url = reverse("oidcfed_trust_mark_status")
c = Client()
# Legacy: sub + trust_mark_id -> 200 + JWT (application/trust-mark-status-response+jwt)
res = c.post(
url,
data={
"trust_mark_id": self.rp_assigned_profile.profile.profile_id,
"sub": self.rp_assigned_profile.descendant.sub,
},
)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.get("Content-Type"), "application/trust-mark-status-response+jwt")
payload = unpad_jwt_payload(res.content.decode())
self.assertEqual(payload.get("status"), "active")
self.assertIn("trust_mark", payload)
self.assertIn("iss", payload)
self.assertIn("iat", payload)
# Draft 48: trust_mark JWT only -> 200 + JWT
res = c.post(
url,
data={"trust_mark": self.rp_assigned_profile.trust_mark["trust_mark"]},
)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.get("Content-Type"), "application/trust-mark-status-response+jwt")
payload = unpad_jwt_payload(res.content.decode())
self.assertEqual(payload.get("status"), "active")
# Legacy: GET with sub + trust_mark_id -> 200 + JWT
res = c.get(
url,
data={
"trust_mark_id": self.rp_assigned_profile.profile.profile_id,
"sub": self.rp_assigned_profile.descendant.sub,
},
)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.get("Content-Type"), "application/trust-mark-status-response+jwt")
# Empty request -> 400 invalid_request
res = c.get(url, data={})
self.assertEqual(res.status_code, 400)
self.assertEqual(res.json().get("error"), "invalid_request")
# Invalid trust_mark JWT -> 400
res = c.get(
url,
data={"trust_mark": self.rp_assigned_profile.trust_mark["trust_mark"][1:]},
)
self.assertEqual(res.status_code, 400)
# trust_mark_id without sub -> 400
res = c.get(
url,
data={"trust_mark_id": self.rp_assigned_profile.profile.profile_id},
)
self.assertEqual(res.status_code, 400)
# Unknown trust mark (wrong sub) -> 404 not_found
res = c.get(
url,
data={
"trust_mark_id": self.rp_assigned_profile.profile.profile_id,
"sub": "https://nonexistent.example/",
},
)
self.assertEqual(res.status_code, 404)
self.assertEqual(res.json().get("error"), "not_found")
class TrustChainWithSignedJwksUriTest(TestCase):
def setUp(self):
self.ta_conf = FederationEntityConfiguration.objects.create(**ta_conf_data)
_rp_conf = copy.deepcopy(rp_conf)
_rp_conf['metadata']['openid_relying_party'].pop('jwks')
_rp_conf['metadata']['openid_relying_party']['signed_jwks_uri'] = f"{_rp_conf['sub']}signed_jwks.jose"
self.rp_conf = FederationEntityConfiguration.objects.create(**_rp_conf)
self.rp_profile = FederationEntityProfile.objects.create(**RP_PROFILE)
self.rp = FederationDescendant.objects.create(**rp_onboarding_data)
self.rp_assigned_profile = FederationEntityAssignedProfile.objects.create(
descendant=self.rp, profile=self.rp_profile, issuer=self.ta_conf
)
@override_settings(HTTP_CLIENT_SYNC=True)
@patch("requests.get", return_value=EntityResponseNoIntermediateSignedJwksUri())
def test_signed_jwks_uri(self, mocked):
self.ta_conf.constraints = {"max_path_length": 0}
self.ta_conf.save()
jwt = get_entity_configurations(self.ta_conf.sub)
trust_anchor_ec = EntityConfiguration(jwt[0])
trust_chain = trust_chain_builder(
subject=rp_onboarding_data["sub"],
trust_anchor=trust_anchor_ec
)
_jwks = get_jwks(
trust_chain.final_metadata['openid_relying_party'],
trust_chain.subject_configuration.jwks
)
self.assertTrue(
verify_jws(_jwks, self.rp_conf.jwks_fed[0])
)
@@ -0,0 +1,48 @@
from copy import deepcopy
from unittest.mock import patch
from django.test import TestCase
from spid_cie_oidc.authority.tests.settings import (
RP_CONF_AS_JSON,
RP_METADATA_JWK1
)
from spid_cie_oidc.authority.utils import random_token
from spid_cie_oidc.authority.validators import validate_entity_configuration
from spid_cie_oidc.entity.exceptions import MissingAuthorityHintsClaim, NotDescendant
from spid_cie_oidc.entity.jwtse import create_jws
from spid_cie_oidc.entity.models import FederationEntityConfiguration
from spid_cie_oidc.entity.tests.settings import ta_conf_data
JWS = create_jws(RP_CONF_AS_JSON, RP_METADATA_JWK1, typ="entity-statement+jwt")
RP_CONF_AS_JSON_NO_HINTS = deepcopy(RP_CONF_AS_JSON)
RP_CONF_AS_JSON_NO_HINTS.pop("authority_hints")
JWS_NO_HINTS = create_jws(RP_CONF_AS_JSON_NO_HINTS, RP_METADATA_JWK1, typ="entity-statement+jwt")
RP_CONF_AS_JSON_WRONG_HINTS = deepcopy(RP_CONF_AS_JSON)
RP_CONF_AS_JSON_WRONG_HINTS["authority_hints"] = ["http://wrong.url.it"]
JWS_WRONG_HINTS = create_jws(RP_CONF_AS_JSON_WRONG_HINTS, RP_METADATA_JWK1, typ="entity-statement+jwt")
class ValidatorTest(TestCase):
def setUp(self):
self.ta_conf = FederationEntityConfiguration.objects.create(**ta_conf_data)
@patch("spid_cie_oidc.authority.validators.get_entity_configurations", return_value = [JWS])
def test_validator(self, mocked):
validate_entity_configuration(["url_entity"])
@patch("spid_cie_oidc.authority.validators.get_entity_configurations", return_value = [JWS_NO_HINTS])
def test_validator_no_hints(self, mocked):
with self.assertRaises(MissingAuthorityHintsClaim):
validate_entity_configuration(["url_entity"])
@patch("spid_cie_oidc.authority.validators.get_entity_configurations", return_value = [JWS_WRONG_HINTS])
def test_validator_no_descendant(self, mocked):
with self.assertRaises(NotDescendant):
validate_entity_configuration(["url_entity"])
class UtilityTests(TestCase):
def test_random_token(self):
self.assertTrue(random_token())
@@ -0,0 +1,49 @@
from copy import deepcopy
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from spid_cie_oidc.authority.models import FederationDescendant
from spid_cie_oidc.authority.tests.settings import rp_onboarding_data
from spid_cie_oidc.entity.models import FederationEntityConfiguration
from spid_cie_oidc.entity.tests.settings import ta_conf_data
class AdvanceEntityListing(TestCase):
def setUp(self):
FederationDescendant.objects.create(**rp_onboarding_data)
rp_onboarding_data_local = deepcopy(rp_onboarding_data)
rp_onboarding_data_local["sub"] = "http://rp-test.it/oidc/rplocal/"
FederationDescendant.objects.create(**rp_onboarding_data_local)
self.ta = FederationEntityConfiguration.objects.create(**ta_conf_data)
@override_settings(MAX_ENTRIES_PAGE=1)
def test_advanced_entity_listing_1(self):
url = reverse("oidcfed_advanced_entity_listing")
data = {"page":2}
res = Client().get(url, data)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json().get("iss"), "http://testserver/")
self.assertEqual(len(res.json().get("entities")), 2)
self.assertEqual(res.json().get("page"), 2)
url_prev_page = reverse("oidcfed_advanced_entity_listing")
self.assertEqual(res.json().get("prev_page_path"), f"{url_prev_page}?page=1")
@override_settings(MAX_ENTRIES_PAGE=1)
def test_advanced_entity_listing_no_page(self):
url = reverse("oidcfed_advanced_entity_listing")
res = Client().get(url)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json().get("iss"), "http://testserver/")
self.assertEqual(len(res.json().get("entities")), 2)
self.assertEqual(res.json().get("page"), 1)
url_next_page = reverse("oidcfed_advanced_entity_listing")
self.assertEqual(res.json().get("next_page_path"), f"{url_next_page}?page=2")
def test_advanced_entity_listing_missing_trust_anchor(self):
FederationEntityConfiguration.objects.all().delete()
c = Client()
url = reverse("oidcfed_advanced_entity_listing")
res = c.get(url)
self.assertEqual(res.status_code, 404)
self.assertEqual(res.json().get("error"), "Missing trust anchor")
@@ -0,0 +1,51 @@
from django.test import TestCase
from spid_cie_oidc.authority.schemas.advanced_entity_list_endpoint import AdvancedEntityListRequest
from spid_cie_oidc.authority.schemas.fetch_endpoint_request import FetchRequest
from spid_cie_oidc.authority.schemas.list_endpoint import ListRequest
from spid_cie_oidc.authority.schemas.trust_mark_status_endpoint import TrustMarkRequest
from spid_cie_oidc.authority.tests.settings import (
FETCH_REQUEST,
LIST_REQUEST,
TRUST_MARK_REQUEST,
ADVANCED_LIST_REQUEST,
TRUST_MARK_REQUEST_NO_SUB_ID,
TRUST_MARK_REQUEST_NO_TRUST_MARK,
TRUST_MARK_REQUEST_TRUST_MARK_NO_ID_NO_TRUST_MARK,
TRUST_MARK_REQUEST_TRUST_MARK_NO_SUB,
TRUST_MARK_REQUEST_TRUST_MARK_NO_SUB_NO_TRUST_MARK,
)
class SchemaTest(TestCase):
def setUp(self) -> None:
return super().setUp()
def test_fetch_request(self):
FetchRequest(**FETCH_REQUEST)
def test_list_request(self):
ListRequest(**LIST_REQUEST)
def test_trust_mark_request(self):
TrustMarkRequest(**TRUST_MARK_REQUEST)
def test_trust_mark_request_no_sub_id(self):
TrustMarkRequest(**TRUST_MARK_REQUEST_NO_SUB_ID)
def test_trust_mark_request_no_rust_mark(self):
TrustMarkRequest(**TRUST_MARK_REQUEST_NO_TRUST_MARK)
def test_trust_mark_request_no_sub(self):
TrustMarkRequest(**TRUST_MARK_REQUEST_TRUST_MARK_NO_SUB)
def test_trust_mark_request_no_id_no_tust_mark(self):
# Schema allows optional fields; view returns 400 if neither (sub+id) nor trust_mark provided
TrustMarkRequest(**TRUST_MARK_REQUEST_TRUST_MARK_NO_ID_NO_TRUST_MARK)
def test_trust_mark_request_no_sub_no_tust_mark(self):
# Schema allows optional fields; view returns 400 if neither (sub+id) nor trust_mark provided
TrustMarkRequest(**TRUST_MARK_REQUEST_TRUST_MARK_NO_SUB_NO_TRUST_MARK)
def test_advanced_list_request(self):
AdvancedEntityListRequest(**ADVANCED_LIST_REQUEST)
@@ -0,0 +1,42 @@
"""acsia_agent_proj URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.urls import path
from .views import (
entity_list,
fetch,
trust_mark_status,
advanced_entity_listing,
trust_marked_list
)
_PREF = getattr(settings, "OIDC_PREFIX", "")
urlpatterns = [
path(f"{_PREF}fetch", fetch, name="oidcfed_fetch"),
path(f"{_PREF}list", entity_list, name="oidcfed_list"),
path(
f"{_PREF}trust_mark_status",
trust_mark_status,
name="oidcfed_trust_mark_status",
),
path(
f"{_PREF}advanced_entity_listing",
advanced_entity_listing,
name="oidcfed_advanced_entity_listing",
),
path(f"{_PREF}trust_marked_list", trust_marked_list, name="oidcfed_tm_list"),
]
@@ -0,0 +1,5 @@
from secrets import token_hex
def random_token(n=254):
return token_hex(n)
@@ -0,0 +1,60 @@
import logging
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.module_loading import import_string
from spid_cie_oidc.entity.statements import (
OIDCFED_FEDERATION_WELLKNOWN_URL,
get_entity_configurations,
EntityConfiguration,
)
from spid_cie_oidc.entity import settings as entity_settings
from spid_cie_oidc.entity.exceptions import MissingAuthorityHintsClaim, NotDescendant
logger = logging.getLogger(__name__)
HTTPC_PARAMS = getattr(settings, "HTTPC_PARAMS", entity_settings.HTTPC_PARAMS)
def validate_entity_configuration(value):
"""
value is the sub url
"""
jwt = None
try:
jwt = get_entity_configurations(value, httpc_params = HTTPC_PARAMS)[0]
except Exception as e:
raise ValidationError(
f"Failed to fetch Entity Configuration for {value}: {e}"
)
if not jwt: # pragma: no cover
raise ValidationError(
"failed to get a valid Entity Configuration from "
f"{value}{OIDCFED_FEDERATION_WELLKNOWN_URL}"
)
try:
ec = EntityConfiguration(jwt, httpc_params=HTTPC_PARAMS)
ec.validate_by_itself()
except Exception as e: # pragma: no cover
raise ValidationError(
f"Failed to validate the Entity Configuration for {value}: {e}"
)
authority_hints = ec.payload.get("authority_hints", [])
if not authority_hints:
raise MissingAuthorityHintsClaim(
"authority_hints must be present in "
"a descendant entity configuration"
)
superior_sub = import_string(
"spid_cie_oidc.entity.models.get_first_self_trust_anchor"
)().sub
if superior_sub not in authority_hints:
raise NotDescendant(
f"This participant MUST have {superior_sub} in "
f"its authority_hints claim. It has: {authority_hints}."
)
return ec
@@ -0,0 +1,301 @@
import logging
import math
import urllib.parse
from djagger.decorators import schema
from django.conf import settings
from django.core.paginator import Paginator
from django.http import (
HttpResponse,
JsonResponse,
)
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from spid_cie_oidc.authority.models import (
FederationDescendant,
FederationEntityAssignedProfile
)
from spid_cie_oidc.authority.settings import MAX_ENTRIES_PAGE
from spid_cie_oidc.entity.jwtse import (
create_jws,
unpad_jwt_head,
unpad_jwt_payload,
)
from spid_cie_oidc.entity.models import get_first_self_trust_anchor
from spid_cie_oidc.entity.statements import get_trust_mark_type_id
from spid_cie_oidc.entity.utils import iat_now
from . schemas.fetch_endpoint_request import FetchRequest, FedAPIErrorResponse, FetchResponse
from . schemas.list_endpoint import ListRequest, ListResponse
from . schemas.advanced_entity_list_endpoint import AdvancedEntityListRequest, AdvancedEntityListResponse
from . schemas.trust_mark_status_endpoint import (
TrustMarkRequest,
TrustMarkResponse,
)
logger = logging.getLogger(__name__)
@schema(
methods=['GET'],
get_request_schema = {
"application/x-www-form-urlencoded": FetchRequest
},
get_response_schema = {
"400": FedAPIErrorResponse,
"404": FedAPIErrorResponse,
"200": FetchResponse
},
tags = ['Federation API']
)
def fetch(request):
"""
OpenID Federation 8.1: Fetch endpoint.
Request: GET with sub REQUIRED (issuer implied by this endpoint's URL).
"""
iss = get_first_self_trust_anchor()
if not request.GET.get("sub"):
conf = get_first_self_trust_anchor()
if request.GET.get("format") == "json":
return JsonResponse(conf.entity_configuration_as_dict, safe=False)
return HttpResponse(
conf.entity_configuration_as_jws,
content_type="application/entity-statement+jwt"
)
sub = FederationDescendant.objects.filter(
sub=request.GET["sub"], is_active=True
).first()
if not sub:
return JsonResponse(
{
"error": "invalid_subject",
"error_description": "entity not found"
},
status=404
)
if request.GET.get("format") == "json":
return JsonResponse(
sub.entity_statement_as_dict(iss.sub), safe=False
)
return HttpResponse(
sub.entity_statement_as_jws(iss.sub),
content_type="application/entity-statement+jwt",
)
@schema(
methods=['GET'],
get_request_schema = {
"application/x-www-form-urlencoded": ListRequest
},
get_response_schema = {
"400": FedAPIErrorResponse,
"404": FedAPIErrorResponse,
"200": ListResponse
},
tags = ['Federation API']
)
def entity_list(request):
"""
OpenID Federation 8.2: Subordinate Listing.
Optional filters: entity_type, trust_marked, trust_mark_type, intermediate.
"""
_q = {"descendant__is_active": True}
if request.GET.get("intermediate", "").lower() == "true":
_q["profile__profile_category"] = "federation_entity"
elif request.GET.get("entity_type"):
_q["profile__profile_category"] = request.GET["entity_type"]
if request.GET.get("trust_mark_type"):
_q["profile__profile_id"] = request.GET["trust_mark_type"]
entries = FederationEntityAssignedProfile.objects.filter(**_q).values_list(
"descendant__sub", flat=True
)
return JsonResponse(list(set(entries)), safe=False)
# TODO - add the schema
# @schema(
# methods=['GET'],
# get_request_schema = {
# "application/x-www-form-urlencoded": ListRequest
# },
# get_response_schema = {
# "400": FedAPIErrorResponse,
# "404": FedAPIErrorResponse,
# "200": ListResponse
# },
# tags = ['Federation API']
# )
def trust_marked_list(request):
if request.GET.get("trust_mark_id", "").lower():
_q = {"profile__profile_id": request.GET["trust_mark_id"]}
else:
_q = {}
entries = FederationEntityAssignedProfile.objects.filter(**_q).values_list(
"descendant__sub", flat=True
)
return JsonResponse(list(set(entries)), safe=False)
@schema(
methods=['GET'],
get_request_schema = {
"application/x-www-form-urlencoded": AdvancedEntityListRequest
},
get_response_schema = {
"400": FedAPIErrorResponse,
"404": FedAPIErrorResponse,
"200": AdvancedEntityListResponse
},
tags = ['Federation API']
)
def advanced_entity_listing(request):
desecendants = FederationDescendant.objects.filter(
is_active = True,
).order_by("-modified")
entities_list = []
for descendant in desecendants:
try:
_ss = descendant.entity_statement_as_jws()
except AttributeError as e:
logger.warning(
f"Subordinate {descendant} missing authority hint: {e}"
)
continue
entity = {
descendant.sub : {
"iat" : int(descendant.modified.timestamp()),
"subordinate_statement": _ss
}
}
entities_list.append(entity)
total_entries = desecendants.count()
_max_entries = getattr(settings, 'MAX_ENTRIES_PAGE', MAX_ENTRIES_PAGE)
p = Paginator(entities_list, _max_entries)
page = request.GET.get("page", 1)
entities = p.get_page(page)
next_page_path = ""
if entities.has_next():
param = {"page": entities.next_page_number()}
url = f'{reverse("oidcfed_advanced_entity_listing")}?{urllib.parse.urlencode(param)}'
next_page_path = f"{url}"
prev_page_path = ""
if entities.has_previous():
param = {"page": entities.previous_page_number()}
url = f'{reverse("oidcfed_advanced_entity_listing")}?{urllib.parse.urlencode(param)}'
prev_page_path = f"{url}"
try:
iss = get_first_self_trust_anchor().sub
except Exception:
return JsonResponse(
{
"error": "Missing trust anchor",
},
status = 404
)
res = {
"iss" : iss,
"iat" : iat_now(),
"entities" : entities_list,
"page" : int(page),
"total_pages" : math.ceil(total_entries / MAX_ENTRIES_PAGE),
"total_entries" : total_entries,
"next_page_path": next_page_path,
"prev_page_path": prev_page_path,
}
return JsonResponse(res, safe=False)
@schema(
methods=['GET', 'POST'],
get_request_schema={
"application/x-www-form-urlencoded": TrustMarkRequest
},
get_response_schema={
"400": FedAPIErrorResponse,
"404": FedAPIErrorResponse,
"200": TrustMarkResponse
},
tags=['Federation API']
)
@csrf_exempt
def trust_mark_status(request):
"""
OpenID Federation 8.4: Trust Mark Status endpoint.
Request: trust_mark (JWT) REQUIRED [draft 48], or legacy: sub + trust_mark_id/trust_mark_type/id.
Response: 200 application/trust-mark-status-response+jwt (signed JWT), or 404 not_found.
"""
sub = request.POST.get("sub") or request.GET.get("sub", None)
_id = (
request.POST.get("trust_mark_type") or request.GET.get("trust_mark_type")
or request.POST.get("trust_mark_id") or request.GET.get("trust_mark_id")
or request.POST.get("id") or request.GET.get("id")
)
trust_mark_jwt = request.POST.get("trust_mark") or request.GET.get("trust_mark", None)
if request.method not in ("GET", "POST"):
return JsonResponse(
{"error": "invalid_request", "error_description": "Method not allowed"},
status=400
)
if trust_mark_jwt:
try:
unpad_jwt_head(trust_mark_jwt)
payload = unpad_jwt_payload(trust_mark_jwt)
sub = payload.get("sub")
_id = get_trust_mark_type_id(payload)
except Exception:
return JsonResponse(
{"error": "invalid_request", "error_description": "Invalid trust_mark JWT"},
status=400
)
elif not (sub and _id):
return JsonResponse(
{"error": "invalid_request", "error_description": "trust_mark REQUIRED or sub + trust_mark_type/id"},
status=400
)
assigned = FederationEntityAssignedProfile.objects.filter(
descendant__sub=sub,
profile__profile_id=_id,
descendant__is_active=True,
).select_related("issuer").first()
if not assigned:
return JsonResponse(
{"error": "not_found", "error_description": "Trust Mark not found or not active"},
status=404
)
# Draft 48: response is signed JWT with iss, iat, trust_mark, status
tm_jwt_for_response = trust_mark_jwt or assigned.trust_mark.get("trust_mark") or assigned.trust_mark_as_jws
issuer = assigned.issuer
jwk = issuer.jwks_fed[0]
status_payload = {
"iss": issuer.sub,
"iat": iat_now(),
"trust_mark": tm_jwt_for_response,
"status": "active",
}
signed = create_jws(
status_payload,
jwk,
alg=issuer.default_signature_alg,
protected={"typ": "trust-mark-status-response+jwt", "kid": jwk.get("kid")},
)
return HttpResponse(
signed,
content_type="application/trust-mark-status-response+jwt",
status=200,
)