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
+233
View File
@@ -0,0 +1,233 @@
# SPID/CIE OIDC Federation SDK
![CI build](https://github.com/peppelinux/spid-cie-oidc-django/workflows/spid_cie_oidc/badge.svg)
![Python version](https://img.shields.io/badge/license-Apache%202-blue.svg)
![py-versions](https://img.shields.io/badge/python-3.10-blue.svg)
[![GitHub issues](https://img.shields.io/github/issues/italia/spid-cie-oidc-django.svg)](https://github.com/italia/spid-cie-oidc-django/issues)
[![Get invited](https://slack.developers.italia.it/badge.svg)](https://slack.developers.italia.it/)
[![Join the #spid openid](https://img.shields.io/badge/Slack%20channel-%23spid%20openid-blue.svg)](https://developersitalia.slack.com/archives/C7E85ED1N/)
SPID/CIE OIDC Federation is a suite of Django applications designed to
make it easy to build an [Openid Connect Federation](https://openid.net/specs/openid-connect-federation-1_0.html),
each of these can be installed separately within a django project. These are the following:
| Application | Description |
| :--- | --- |
| __spid_cie_oidc.accounts__ | Customizable application that extends the django User model. |
| __spid_cie_oidc.entity__ | OpenID Connect Federation django app that implements OIDC Federation 1.0 Entity Statements, metadata discovery, Trust Chain, Trust Marks and Metadata policy. Technical specifications: [__OIDC Federation Entity__](docs/technical_specifications/ENTITY.md) |
| __spid_cie_oidc.authority__ | OpenID Connect Federation API and models for __OIDC Federation Trust Chain/Intermediate__, [Technical specifications](docs/technical_specifications/AUTHORITY.md) and [tutorial](docs/CREATE_A_FEDERATION.md).
| __spid_cie_oidc.onboarding__ | [__OpenID Connect Federation onboarding demo service__](docs/technical_specifications/ONBOARDING.md) and tools|
| __spid_cie_oidc.relying_party__ | [__OpenID Connect Relying Party__](docs/technical_specifications/RELYING_PARTY.md) and test suite for OIDC Providers |
| __spid_cie_oidc.provider__ | [__OpenID Connect Provider__](docs/technical_specifications/PROVIDER.md) and test suite for OIDC Relying Parties |
## Summary
* [Features](#features)
* [Setup](#setup)
* [Docker](#docker)
* [Usage](#usage)
* [OpenAPI Schema 3](#openapi-schema-3)
* [Tools](#tools)
* [Contribute](#contribute)
* [Contribute as end user](#contribute-as-end-user)
* [Contribute as developer](#contribute-as-developer)
* [Implementations notes](#implementation-notes)
* [License and Authors](#license-and-authors)
------------------------------------------------
![RP Auth demo](docs/images/rp_auth_demo_4.gif)
_An onboarded Relying Party with a succesful authentication._
## Setup
All the Django apps are available in the folder `spid_cie_oidc/`.
The examples projects are available in the folder `examples/`.
There is a substantial difference between an app and a project.
The app is installed using a common python package manager, such as _poetry_ or _pip_,
and can be used, inherited, and integrated into other projects.
A project is a service configuration that integrates one or more applications.
In this repository we have three example projects:
- federation_authority
- relying_party
- provider
Federation Authority loads all the applications for development needs, acting as both authority, SPID RP and SPID OP.
This allows us to make a demo by starting a single service. See admin page `http://127.0.0.1:8000/admin/` and user login page `http://127.0.0.1:8000/oidc/rp/landing/`.
Then we have also another Relying Party, as indipendent project, and another Provider configured with the CIE profile.
Relying party and Provider are examples that only integrate
__spid_cie_oidc.entity__ and __spid_cie_oidc.provider__ or __.relying_party__ as applications.
Read the [setup documentation](docs/SETUP.md) to get started.
## Docker
### Docker image
````
docker pull ghcr.io/italia/spid-cie-oidc-django:latest
````
### Docker compose
Please do your customizations in each _settingslocal.py_ files and/or in the example dumps json file.
Change hostnames from 127.0.0.1 to which one configured in the compose file, in the settingslocal.py files and in the dumps/example.json files.
In our example we rename:
- http://127.0.0.1:8000 to http://trust-anchor.org:8000/
- http://127.0.0.1:8001 to http://relying-party.org:8001/
- http://127.0.0.1:8002 to http://cie-provider.org:8002/
We can do that with the following steps:
- Execute `bash docker-prepare.sh`
- Customize the example data and settings contained in `examples-docker/` if needed (not necessary for a quick demo)
Run the stack
````
sudo docker-compose up
````
Configure a proper DNS resolution for trust-anchor.org. In GNU/Linux we can configure it in `/etc/hosts`:
````
127.0.0.1 localhost trust-anchor.org relying-party.org cie-provider.org wallet.trust-anchor.org
````
Point your web browser to `http://relying-party.org:8001/oidc/rp/landing` and do your first oidc authentication.
## Usage
The demo proposes a small federation composed by the following entities:
- Federation Authority, acts as trust anchor and onboarding system. It's available at `http://127.0.0.1:8000/`. It has also an embedded Spid provider and a embedded Relying Party available at `/oidc/rp/landing`.
- OpenID Relying Party, available at `http://127.0.0.1:8001/`
- CIE OpenID Provider, available at `http://127.0.0.1:8002/`
In the docker example we have only the Federation Authority with an embedded SPID OP and a RP.
Examples Users and Passwords:
- __admin__ __oidcadmin__
- __user__ __oidcuser__
## OpenAPI Schema 3
Each application has an exportable OAS3 available at `/rest/schema.json` with a browsable reDoc UI at `/rest/api/docs`.
![RP Auth demo](docs/images/redoc.png)
_The reDoc OAS3 browsable page._
## Tools
The OnBoarding app comes with the following collection of tools:
- JWK
- Create a jwk
- Convert a private JWK to PEM
- Convert a public JWK to PEM
- Convert a private PEM to JWK
- Convert a public PEM to JWK
- JWT decode and verification
- Federation
- Resolve entity statement
- Apply policy
- Validators
- Validate OP metadata spid
- Validate OP metadata cie
- Validate RP metadata spid
- Validate RP metadata cie
- Validate Authn Request spid
- Validate Authn Request cie
- Validate Entity Configuration
- Trust mark validation
- Schemas
- Authorization Endpoint
- Introspection Endpoint
- Metadata
- Token Endpoint
- Revocation Endpoint
- Jwt client Assertion
![OIDC Tools](docs/images/onboard_tools_jwt_debug.png)
_OIDC tools facilitates the lives of developers and service operators, here a simple interface to decode and verify a JWT._
To explore a federation on the commandline, use the [ofcli](https://github.com/dianagudu/ofcli) tool. It can be used to export federation metadata to json files for further analysis.
## Contribute
Your contribution is welcome, no question is useless and no answer is obvious, we need you.
#### Contribute as end user
Please open an issue if you've discoveerd a bug or if you want to ask some features.
#### Contribute as developer
Please open your Pull Requests on the __dev__ branch.
Please consider the following branches:
- __main__: where we merge the code before tag a new stable release.
- __dev__: where we push our code during development.
- __other-custom-name__: where a new feature/contribution/bugfix will be handled, revisioned and then merged to dev branch.
Backup and share your demo data
````
# backup your data (upgrade example data), -e excludes.
./manage.py dumpdata -e admin -e spid_cie_oidc_relying_party -e spid_cie_oidc_provider -e spid_cie_oidc_relying_party_test -e auth -e contenttypes -e sessions --indent 2 > dumps/example.json
````
In this project we adopt [Semver](https://semver.org/lang/it/) and
[Conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) specifications.
## Implementation notes
All the operation related to JWT signature and encryption are built on top of [IdentityPython](https://idpy.org/)
[cryptojwt](https://github.com/IdentityPython/JWTConnect-Python-CryptoJWT)
This project proposes an implementation of the italian OIDC Federation profile with
__automatic_client_registration__ and the adoption of the trust marks as mandatory.
If you're looking for a fully compliant implementation of OIDC Federation 1.0,
with a full support of explicit client registration, please look at idpy's
[fedservice](https://github.com/rohe/fedservice).
#### General Features
- SPID and CIE OpenID Connect Provider
- SPID and CIE OpenID Connect Relying Party
- OIDC Federation onboarding demo service
- OIDC Federation 1.0
- Trust Anchor and Intermediary
- Automatic client registration
- Entity profiles and Trust marks
- Trust chain storage and discovery
- Entity statement resolve endpoint
- Fetch statement endpoing
- List entities endpoint
- Advanced List endpoint
- Federation CLI
- RP: build trust chains for all the available OPs
- OP: build trust chains for all the available RPs
- Multitenancy, a single service can configure many entities like RPs, OP, Trust Anchors and intermediaries
- gettext compliant (i18n)
- Bootstrap Italia Design templates
## License and Authors
This software is released under the Apache 2 License by:
- Giuseppe De Marco <gi.demarco@innovazione.gov.it>.
In this project we use the
[metadata policy code](https://github.com/peppelinux/spid-cie-oidc-django/blob/main/spid_cie_oidc/entity/policy.py)
written by Roland Hedberg and licensed under the same Apache 2 license.
+75
View File
@@ -0,0 +1,75 @@
import re
import os
import re
import sys
from glob import glob
from setuptools import setup, find_packages
SRC_FOLDER = '.'
PKG_NAME = 'spid_cie_oidc'
INSTALL_REQUIRES = [
"Django>=4.0,<5.0",
"cryptojwt>=1.8.2",
"pydantic>=1.8.2,<2.0",
"pytz>=2021.3",
"aiohttp>=3.9,<4",
"requests>=2.32.0,<3.0",
"pydantic[email]",
"djagger",
# Security: pin transitive deps to fix known CVEs (pip-audit)
"certifi>=2024.7.4",
"idna>=3.7",
"sqlparse>=0.4.4",
"urllib3>=2.2.0",
"setuptools>=78.1.1",
]
with open(os.path.join(os.path.dirname(__file__), 'README.md'), encoding='utf-8') as readme:
README = readme.read()
with open(f'{SRC_FOLDER}{os.path.sep}{PKG_NAME}/__init__.py', 'r') as fd:
VERSION = re.search(
r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]',
fd.read(), re.MULTILINE
).group(1)
LICENSE = "License :: OSI Approved :: Apache Software License"
setup(
name=PKG_NAME,
version=VERSION,
description="SPID/CIE OIDC Federation Entity",
long_description=README,
long_description_content_type='text/markdown',
author='Giuseppe De Marco',
author_email='[email protected]',
license=LICENSE,
url=f"https://github.com/peppelinux/{PKG_NAME.replace('_', '-')}",
packages=[PKG_NAME, ],
package_dir={f"{PKG_NAME}": f"{SRC_FOLDER}/{PKG_NAME}"},
package_data={
f"{PKG_NAME}": [
i.replace(
f'{SRC_FOLDER}{os.path.sep}{PKG_NAME}{os.path.sep}', ''
)
for i in glob(
f'{SRC_FOLDER}{os.path.sep}{PKG_NAME}{os.path.sep}**',
recursive=True
)
if i and '__pycache__' not in i
]
},
classifiers=[
"Development Status :: 5 - Production/Stable",
LICENSE,
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Software Development :: Libraries :: Python Modules"],
install_requires=INSTALL_REQUIRES,
zip_safe=False,
)
+1
View File
@@ -0,0 +1 @@
__version__ = "1.6.3"
@@ -0,0 +1,74 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.admin import UserAdmin
from .models import User
@admin.register(User)
class CustomUserAdmin(UserAdmin):
readonly_fields = (
"date_joined",
"last_login",
)
list_display = (
"username",
"email",
"is_active",
"is_staff",
"is_superuser",
)
list_editable = (
"is_active",
"is_staff",
"is_superuser",
)
fieldsets = (
(
None,
{
"fields": (
(
"username",
"is_active",
"is_staff",
"is_superuser",
),
("password"),
("origin"),
)
},
),
(
_("Anagrafica"),
{"fields": (("first_name", "last_name"), "email", ("attributes",))},
),
(
_("Permissions"),
{"fields": ("groups", "user_permissions"), "classes": ("collapse",)},
),
(
_("Date accessi sistema"),
{
"fields": (
(
"date_joined",
"last_login",
)
)
},
),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("username", "password1", "password2"),
},
),
)
admin.site.unregister(User)
admin.site.register(User, CustomUserAdmin)
@@ -0,0 +1,9 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class AccountsConfig(AppConfig):
name = "spid_cie_oidc.accounts"
verbose_name = _("User accounts")
default_auto_field = "django.db.models.BigAutoField"
label = "spid_cie_oidc_accounts"
@@ -0,0 +1,145 @@
# Generated by Django 4.0.2 on 2022-02-06 15:45
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"first_name",
models.CharField(
blank=True, max_length=96, null=True, verbose_name="Name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=96, null=True, verbose_name="Surname"
),
),
("is_active", models.BooleanField(default=True, verbose_name="active")),
(
"email",
models.EmailField(
blank=True,
max_length=254,
null=True,
verbose_name="email address",
),
),
(
"taxpayer_id",
models.CharField(
blank=True,
max_length=32,
null=True,
verbose_name="Taxpayer's identification number",
),
),
(
"origin",
models.CharField(
blank=True,
max_length=254,
null=True,
verbose_name="from which connector this user come from",
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.Group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.Permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name_plural": "Users",
"ordering": ["username"],
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
]
@@ -0,0 +1,22 @@
# Generated by Django 4.0.2 on 2022-03-02 17:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_accounts", "0001_initial"),
]
operations = [
migrations.RemoveField(
model_name="user",
name="taxpayer_id",
),
migrations.AddField(
model_name="user",
name="attributes",
field=models.JSONField(blank=True, default={}, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.0.2 on 2022-03-02 17:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_accounts", "0002_remove_user_taxpayer_id_user_attributes"),
]
operations = [
migrations.AlterField(
model_name="user",
name="attributes",
field=models.JSONField(blank=True, default=dict, null=True),
),
]
@@ -0,0 +1,35 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.contrib.sessions.models import Session
from django.utils.translation import gettext_lazy as _
class User(AbstractUser):
first_name = models.CharField(_("Name"), max_length=96, blank=True, null=True)
last_name = models.CharField(_("Surname"), max_length=96, blank=True, null=True)
is_active = models.BooleanField(_("active"), default=True)
email = models.EmailField("email address", blank=True, null=True)
origin = models.CharField(
_("from which connector this user come from"),
max_length=254,
blank=True,
null=True,
)
attributes = models.JSONField(default=dict, blank=True, null=True)
class Meta:
ordering = ["username"]
verbose_name_plural = _("Users")
def clear_sessions(self): # pragma: no cover
user_sessions = []
for session in Session.objects.all():
if str(self.pk) == session.get_decoded().get("_auth_user_id"):
user_sessions.append(session.pk)
return Session.objects.filter(pk__in=user_sessions).delete()
def __str__(self): # pragma: no cover
return "{} {}".format(
self.first_name or self.attributes.get('given_name'),
self.last_name or self.attributes.get('family_name', "")
)
@@ -0,0 +1,10 @@
from django import template
from django.contrib.auth.models import Group
register = template.Library()
@register.filter(name="has_group")
def has_group(user, group_name): # pragma: no cover
group = Group.objects.get(name=group_name)
return group in user.groups.all()
@@ -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,
)
@@ -0,0 +1,6 @@
from enum import Enum
class KeyUsage(str, Enum):
signature = "sig"
encryption = "enc"
@@ -0,0 +1,30 @@
from django.contrib.auth import get_user_model
from django.db import models
class TimeStampedModel(models.Model):
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class CreatedModifiedBy(models.Model):
created_by = models.ForeignKey(
get_user_model(),
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="%(class)s_created_by",
)
modified_by = models.ForeignKey(
get_user_model(),
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="%(class)s_modified_by",
)
class Meta:
abstract = True
@@ -0,0 +1,202 @@
import logging
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.contrib import messages
# from prettyjson import PrettyJSONWidget
from django.conf import settings
from .jwtse import unpad_jwt_payload
from .statements import EntityConfiguration, get_entity_configurations, get_entity_statements
from .models import (
FederationEntityConfiguration,
FederationHistoricalKey,
FetchedEntityStatement,
TrustChain,
StaffToken
)
from spid_cie_oidc.entity.trust_chain_operations import get_or_create_trust_chain
from . settings import HTTPC_PARAMS
logger = logging.getLogger(__name__)
@admin.register(FederationHistoricalKey)
class FederationHistoricalKey(admin.ModelAdmin):
readonly_fields = ('jwk', "as_json")
list_display = ("entity", "kid", "inactive_from")
list_filter = ("created", "modified", "inactive_from")
search_fields = ("entity",)
@admin.register(FederationEntityConfiguration)
class FederationEntityConfigurationAdmin(admin.ModelAdmin):
# formfield_overrides = {
# JSONField: {
# "widget": PrettyJSONWidget(
# attrs={"initial": "parsed", "disabled": True}
# )
# }
# }
@admin.action(description='update trust marks')
def update_trust_marks(modeladmin, request, queryset): # pragma: no cover
"""
fetch trust marks from all the authorities
"""
trust_marks = {}
for obj in queryset:
jwts = get_entity_configurations(obj.authority_hints, HTTPC_PARAMS)
for jwt in jwts:
try:
ec = EntityConfiguration(jwt, httpc_params=HTTPC_PARAMS)
except Exception as e:
_msg = f"Failed getting Entity Configuration for {jwt}: {e}"
logger.warning(_msg)
messages.error(_msg)
continue
try:
# get superior fetch url
fetch_api_url = ec.payload["metadata"]["federation_entity"][
"federation_fetch_endpoint"
]
except KeyError:
_msg = (
"Missing federation_fetch_endpoint in "
f"federation_entity metadata for {obj.sub} by {ec.sub}."
)
logger.warning(_msg)
messages.error(_msg)
continue
_url = f"{fetch_api_url}?sub={obj.sub}"
try:
logger.info(f"Getting entity statements from {_url}")
_jwts = get_entity_statements([_url], HTTPC_PARAMS)
payload = unpad_jwt_payload(_jwts[0])
for i in payload.get("trust_marks", []):
trust_marks[i['id']] = i['trust_mark']
except Exception as e:
_msg = f"Error getting entity statements from {_url}: {e}"
logger.warning(_msg)
messages.error(_msg)
continue
positions = {}
count = 0
for i in obj.trust_marks:
positions[i['id']] = count
count += 1
if positions:
for k,v in trust_marks.items():
if positions.get(k, None):
obj.trust_marks[positions[k]] = {k:v}
else:
obj.trust_marks.append({k:v})
else:
obj.trust_marks = [
{"trust_mark_id":k, "trust_mark":v} for k,v in trust_marks.items()
]
obj.save()
messages.success(
request,
f"Trust mark reloaded succesfully: {', '.join(trust_marks.keys())}"
)
list_display = (
"sub",
"type",
"kids",
"is_active",
"created",
)
list_filter = ("created", "modified", "is_active")
# search_fields = ('command__name',)
readonly_fields = (
"created",
"modified",
"entity_configuration_as_json",
"pems_as_html",
"kids",
"type",
)
actions = [update_trust_marks]
def pems_as_html(self, obj):
res = ""
data = dict()
for k, v in obj.pems_as_dict.items():
data[k] = {}
for i in ("public", "private"):
data[k][i] = v[i].replace("\n", "<br>")
res += (
f"<b>{k}</b><br><br>"
f"{data[k]['public']}<br>"
f"{data[k]['private']}<br><hr>"
)
return mark_safe(res) # nosec
@admin.register(TrustChain)
class TrustChainAdmin(admin.ModelAdmin):
@admin.action(description='reload trust chain')
def update_trust_chain(modeladmin, request, queryset): # pragma: no cover
for tc in queryset:
sub = tc.sub
ta = tc.trust_anchor.sub
try :
get_or_create_trust_chain(
subject=sub,
trust_anchor=ta,
httpc_params=settings.HTTPC_PARAMS,
required_trust_marks=getattr(
settings, "OIDCFED_REQUIRED_TRUST_MARKS", [],
),
force=True
)
messages.success(
request, f"Trust chain successfully reloaded for {sub}"
)
except Exception as e:
messages.error(request, f"Failed to update {sub} due to: {e}")
continue
list_display = ("sub", "exp", "modified", "is_valid")
list_filter = ("exp", "modified", "is_active")
search_fields = ("sub",)
readonly_fields = (
"created",
"modified",
"parties_involved",
"status",
"log",
"chain",
"iat",
)
actions = [update_trust_chain]
@admin.register(FetchedEntityStatement)
class FetchedEntityStatementAdmin(admin.ModelAdmin):
list_display = ("sub", "iss", "exp", "iat", "created", "modified")
list_filter = ("created", "modified", "exp", "iat")
search_fields = ("sub", "iss")
readonly_fields = ("sub", "statement", "created", "modified", "iat", "exp", "iss")
@admin.register(StaffToken)
class StaffTokenAdmin(admin.ModelAdmin):
list_display = ("user", "expire_at", "is_valid")
list_filter = ("created", "modified", "expire_at")
search_fields = ("token", )
readonly_fields = ("is_valid",)
raw_id_fields = ('user',)
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class SpidCieFederationEntityConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "spid_cie_oidc.entity"
verbose_name = "SPID/CIE OIDC Federation Entity"
label = "spid_cie_oidc_entity"
@@ -0,0 +1,53 @@
from django.core.exceptions import ValidationError
class HttpError(Exception):
pass
class TrustChainHttpError(HttpError):
pass
class UnknownKid(Exception):
pass
class MissingJwksClaim(ValidationError):
pass
class MissingAuthorityHintsClaim(ValidationError):
pass
class NotDescendant(ValidationError):
pass
class TrustAnchorNeeded(ValidationError):
pass
class MetadataDiscoveryException(ValidationError):
pass
class MissingTrustMark(ValidationError):
pass
class InvalidRequiredTrustMark(ValidationError):
pass
class InvalidTrustchain(ValidationError):
pass
class TrustchainMissingMetadata(ValidationError):
pass
class InvalidEntityConfiguration(ValidationError):
pass
@@ -0,0 +1,44 @@
import aiohttp
import asyncio
async def fetch(session, url, httpc_params: dict = {}):
async with session.get(url, **httpc_params.get("connection", {})) as response:
if response.status != 200: # pragma: no cover
# response.raise_for_status()
return ""
return await response.text()
async def fetch_all(session, urls, httpc_params):
tasks = []
for url in urls:
task = asyncio.create_task(fetch(session, url, httpc_params))
tasks.append(task)
results = await asyncio.gather(*tasks)
return results
async def http_get(urls, httpc_params: dict = {}):
_con = aiohttp.TCPConnector(**httpc_params.get("connection", {}))
async with aiohttp.ClientSession(
connector=_con,
**httpc_params.get("session", {})
) as session:
text = await fetch_all(session, urls, httpc_params)
return text
if __name__ == "__main__": # pragma: no cover
httpc_params = {
"connection": {"ssl": True},
"session": {"timeout": aiohttp.ClientTimeout(total=4)},
}
urls = [
"http://127.0.0.1:8001/.well-known/openid-federation",
"http://127.0.0.1:8000/.well-known/openid-federation",
"http://asdasd.it",
"http://127.0.0.1:8001/.well-known/openid-federation",
"http://google.it",
]
asyncio.run(http_get(urls, httpc_params=httpc_params))
@@ -0,0 +1,86 @@
import base64
import json
from urllib.parse import urlparse
from typing import Union
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, ed448, rsa
from cryptojwt.jwk.jwk import key_from_jwk_dict
from spid_cie_oidc.entity.x509 import X509Issuer
def _normalize_jwks(jwks):
"""
Normalize jwks to a list of JWK dicts. Handles:
- {"keys": [...]} (JWKS structure from entity config)
- [...] (list of keys)
- Single JWK dict (as item that may be JSON string)
"""
if isinstance(jwks, dict):
if "keys" in jwks:
jwks = jwks["keys"]
else:
jwks = [jwks]
if not isinstance(jwks, list):
jwks = [jwks]
result = []
for item in jwks:
if isinstance(item, dict):
result.append(item)
elif isinstance(item, str):
result.append(json.loads(item))
else:
raise TypeError(f"JWK item must be dict or JSON string, got {type(item)}")
return result
def _public_key_from_jwk_dict(jwk_dict: dict):
"""
Extract public key from a JWK dict. Handles keys with x5c but no kty
(e.g. from entity configurations that publish keys via x5c only).
"""
if "kty" in jwk_dict:
return key_from_jwk_dict(jwk_dict).public_key()
# JWK has x5c but no kty - derive from certificate
x5c = jwk_dict.get("x5c")
if not x5c:
raise ValueError("JWK must have either kty or x5c")
der = base64.b64decode(x5c[0])
cert = x509.load_der_x509_certificate(der)
pub_key = cert.public_key()
# Add kty so the JWK is valid for downstream consumers
if isinstance(pub_key, rsa.RSAPublicKey):
jwk_dict["kty"] = "RSA"
elif isinstance(pub_key, ec.EllipticCurvePublicKey):
jwk_dict["kty"] = "EC"
elif isinstance(pub_key, (ed25519.Ed25519PublicKey, ed448.Ed448PublicKey)):
jwk_dict["kty"] = "OKP"
else:
jwk_dict["kty"] = "RSA" # fallback for unknown types
return pub_key
def update_jwks_with_x5c(
jwks: list,
private_key: bytes,
subject: str,
is_ca_or_int: bool,
path_length: Union[int, None] = None,
) -> dict:
jwks = _normalize_jwks(jwks)
subject_data: dict = dict(
X509_COMMON_NAME=urlparse(subject).hostname,
# TODO: add COUNTRY_NAME, X509_STATE_OR_PROVINCE_NAME, X509_LOCALITY_NAME, X509_ORGANIZATION_NAME
entity_id=subject
)
for i in jwks:
i['x5c'] = X509Issuer(
private_key = private_key,
public_key = _public_key_from_jwk_dict(i),
subject_data = subject_data,
is_ca_or_int = is_ca_or_int,
path_length = path_length
).x5c
return jwks
+121
View File
@@ -0,0 +1,121 @@
from cryptojwt.jwk.jwk import key_from_jwk_dict
from cryptojwt.jwk.rsa import new_rsa_key
from cryptojwt.jwk.ec import new_ec_key
from cryptography.hazmat.primitives import serialization
from cryptojwt.jwk.rsa import RSAKey
from cryptojwt.jwk.ec import ECKey
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from django.conf import settings
from . import settings as local_settings
DEFAULT_HASH_FUNC = getattr(
settings, "DEFAULT_HASH_FUNC", local_settings.DEFAULT_HASH_FUNC
)
def create_jwk(key=None, hash_func=None, typ: str = getattr(settings, "PRIVATE_KEY_TYPE", None)):
if key:
key = key
elif typ and typ.lower() == 'ec':
key = new_ec_key(crv="P-256")
else:
key = new_rsa_key()
thumbprint = key.thumbprint(hash_function=hash_func or DEFAULT_HASH_FUNC)
jwk = key.to_dict()
jwk["kid"] = thumbprint.decode()
return jwk
def public_jwk_from_private_jwk(jwk_dict: dict):
# exports public
_k = key_from_jwk_dict(jwk_dict)
jwk = _k.serialize()
jwk["kid"] = jwk_dict['kid']
return jwk
def private_pem_from_jwk(jwk_dict: dict):
# exports private
_k = key_from_jwk_dict(jwk_dict)
pk = _k.private_key()
pem = pk.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
return pem.decode()
def public_pem_from_jwk(jwk_dict: dict):
# exports private
_k = key_from_jwk_dict(jwk_dict)
pk = _k.public_key()
cert = pk.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
return cert.decode()
def serialize_key(key, kind="public", hash_func="SHA-256"):
"""
key can be:
- cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey
- cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey
- cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey
- cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey
- str or bytes (PEM format)
"""
data = {}
# Handle RSA keys
if isinstance(key, rsa.RSAPublicKey):
data = {"pub_key": key}
elif isinstance(key, rsa.RSAPrivateKey):
data = {"priv_key": key}
# Handle EC keys
elif isinstance(key, ec.EllipticCurvePublicKey):
data = {"pub_key": key}
elif isinstance(key, ec.EllipticCurvePrivateKey):
data = {"priv_key": key}
# Handle PEM strings/bytes
elif isinstance(key, (str, bytes)):
if kind == "private":
if isinstance(key, rsa.RSAPrivateKey) or (isinstance(key, str) and "RSA" in key):
data = {"priv_key": private_jwk_from_pem(key)}
else:
data = {"priv_key": private_jwk_from_pem(key)}
else:
if isinstance(key, rsa.RSAPublicKey) or (isinstance(key, str) and "RSA" in key):
data = {"pub_key": public_jwk_from_pem(key)}
else:
data = {"pub_key": public_jwk_from_pem(key)}
# Create appropriate JWK object based on key type
if isinstance(key, (rsa.RSAPublicKey, rsa.RSAPrivateKey)) or (isinstance(key, str) and "RSA" in key):
jwk_obj = RSAKey(**data)
else:
jwk_obj = ECKey(**data)
thumbprint = jwk_obj.thumbprint(hash_function=hash_func)
jwk = jwk_obj.to_dict()
jwk["kid"] = thumbprint.hex() # Use hex() instead of decode()
return jwk
def private_jwk_from_pem(content:str, password:str = None):
content = content.encode() if isinstance(content, str) else content
key = serialization.load_pem_private_key(content, password=password)
return serialize_key(key, kind='private')
def public_jwk_from_pem(content:str, password:str = None):
content = content.encode() if isinstance(content, str) else content
key = serialization.load_pem_public_key(content)
return serialize_key(key, kind='public')
@@ -0,0 +1,137 @@
import base64
import binascii
import json
import logging
import cryptojwt
from cryptojwt.exception import UnsupportedAlgorithm, VerificationError
from cryptojwt.jwe.jwe import factory
from cryptojwt.jwe.jwe_ec import JWE_EC
from cryptojwt.jwe.jwe_rsa import JWE_RSA
from cryptojwt.jwk.jwk import key_from_jwk_dict
from cryptojwt.jws.jws import JWS
from cryptojwt.jws.utils import left_hash
from typing import Union
from .settings import (
DEFAULT_JWE_ALG,
DEFAULT_JWE_ENC,
ENCRYPTION_ALG_VALUES_SUPPORTED,
SIGNING_ALG_VALUES_SUPPORTED,
)
logger = logging.getLogger(__name__)
def unpad_jwt_element(jwt: str, position: int) -> dict:
b = jwt.split(".")[position]
padded = f"{b}{'=' * divmod(len(b), 4)[1]}"
data = json.loads(base64.urlsafe_b64decode(padded))
return data
def unpad_jwt_head(jwt: str) -> dict:
return unpad_jwt_element(jwt, position=0)
def unpad_jwt_payload(jwt: str) -> dict:
return unpad_jwt_element(jwt, position=1)
def create_jwe(plain_dict: Union[dict, str, int, None], jwk_dict: dict, **kwargs) -> str:
logger.debug(f"Encrypting dict as JWE: " f"{plain_dict}")
_key = key_from_jwk_dict(jwk_dict)
if isinstance(_key, cryptojwt.jwk.rsa.RSAKey):
JWE_CLASS = JWE_RSA
elif isinstance(_key, cryptojwt.jwk.ec.ECKey):
JWE_CLASS = JWE_EC
if isinstance(plain_dict, dict):
_payload = json.dumps(plain_dict).encode()
elif not plain_dict:
logger.warning("create_jwe with null payload!")
_payload = ""
elif isinstance(plain_dict, (str, int)):
_payload = plain_dict
else:
logger.error("create_jwe with unsupported payload type!")
_payload = ""
_keyobj = JWE_CLASS(
_payload,
alg=DEFAULT_JWE_ALG,
enc=DEFAULT_JWE_ENC,
kid=_key.kid,
**kwargs
)
jwe = _keyobj.encrypt(_key.public_key())
logger.debug(f"Encrypted dict as JWE: {jwe}")
return jwe
def decrypt_jwe(jwe: str, jwk_dict: dict) -> dict:
# get header
try:
jwe_header = unpad_jwt_head(jwe)
except (binascii.Error, Exception) as e: # pragma: no cover
logger.error(f"Failed to extract JWT header: {e}")
raise VerificationError("The JWT is not valid")
_alg = jwe_header.get("alg", DEFAULT_JWE_ALG)
_enc = jwe_header.get("enc", DEFAULT_JWE_ENC)
jwe_header.get("kid")
if _alg not in ENCRYPTION_ALG_VALUES_SUPPORTED: # pragma: no cover
raise UnsupportedAlgorithm(f"{_alg} has beed disabled for security reason")
_decryptor = factory(jwe, alg=_alg, enc=_enc)
# _dkey = RSAKey(priv_key=PRIV_KEY)
_dkey = key_from_jwk_dict(jwk_dict)
msg = _decryptor.decrypt(jwe, [_dkey])
try:
msg_dict = json.loads(msg)
logger.debug(f"Decrypted JWT as: {json.dumps(msg_dict, indent=2)}")
except json.decoder.JSONDecodeError:
msg_dict = msg
logger.debug(f"Decrypted JWT as: {msg_dict}")
return msg_dict
def create_jws(payload: dict, jwk_dict: dict, alg: str = "RS256", protected:dict = {}, **kwargs) -> str:
_key = key_from_jwk_dict(jwk_dict)
_signer = JWS(payload, alg=alg, **kwargs)
signature = _signer.sign_compact([_key], protected=protected, **kwargs)
return signature
def verify_jws(jws: str, pub_jwk: dict, **kwargs) -> str:
_key = key_from_jwk_dict(pub_jwk)
_head = unpad_jwt_head(jws)
if _head.get("kid") != pub_jwk["kid"]: # pragma: no cover
raise Exception(
f"kid error: {_head.get('kid')} != {pub_jwk['kid']}"
)
_alg = _head["alg"]
if _alg not in SIGNING_ALG_VALUES_SUPPORTED or not _alg: # pragma: no cover
raise UnsupportedAlgorithm(f"{_alg} has beed disabled for security reason")
verifier = JWS(alg=_head["alg"], **kwargs)
msg = verifier.verify_compact(jws, [_key])
return msg
def verify_at_hash(id_token, access_token) -> bool:
id_token_at_hash = id_token['at_hash']
at_hash = left_hash(access_token, "HS256")
if at_hash != id_token_at_hash:
raise Exception(
f"at_hash error: {at_hash} != {id_token_at_hash}"
)
return True
@@ -0,0 +1,243 @@
# Generated by Django 4.0.2 on 2022-02-13 09:06
from django.db import migrations, models
import spid_cie_oidc.entity.models
import spid_cie_oidc.entity.validators
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="FederationEntityConfiguration",
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)),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
(
"sub",
models.URLField(
help_text="URL that identifies this Entity in the Federation. This value and iss are the same in the Entity Configuration.",
max_length=255,
),
),
(
"default_exp",
models.PositiveIntegerField(
default=33,
help_text="how many minutes from now() an issued statement must expire",
),
),
(
"default_signature_alg",
models.CharField(
default="RS256",
help_text="default signature algorithm, eg: RS256",
max_length=16,
),
),
(
"authority_hints",
models.JSONField(
blank=True,
default=list,
help_text="only required if this Entity is an intermediary",
),
),
(
"jwks",
models.JSONField(
default=spid_cie_oidc.entity.models.FederationEntityConfiguration._create_jwks,
help_text="a list of public keys",
),
),
(
"trust_marks",
models.JSONField(
blank=True,
default=list,
help_text="which trust marks MUST be exposed in its entity configuration",
),
),
(
"trust_mark_issuers",
models.JSONField(
blank=True,
default=dict,
help_text='Only usable for Trust Anchors and intermediates. Which issuers are allowed to issue trust marks for the descendants. Example: {"https://www.spid.gov.it/certification/rp": ["https://registry.spid.agid.gov.it", "https://intermediary.spid.it"],"https://sgd.aa.it/onboarding": ["https://sgd.aa.it", ]}',
),
),
(
"metadata",
models.JSONField(
default=dict,
help_text='federation_entity metadata, eg: {"federation_entity": { ... },"openid_provider": { ... },"openid_relying_party": { ... },"oauth_resource": { ... }}',
validators=[
spid_cie_oidc.entity.validators.validate_entity_metadata
],
),
),
(
"is_active",
models.BooleanField(
default=False,
help_text="If this configuration is active. At least one configuration must be active",
),
),
],
options={
"verbose_name": "Federation Entity Configuration",
"verbose_name_plural": "Federation Entity Configurations",
},
),
migrations.CreateModel(
name="PublicJwk",
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)),
(
"kid",
models.CharField(
help_text="unique code that identifies this jwks. ",
max_length=1024,
unique=True,
),
),
(
"jwk",
models.JSONField(
default=dict,
help_text="Public jwk",
validators=[
spid_cie_oidc.entity.validators.validate_public_jwks
],
),
),
],
options={
"verbose_name": "Public JWKs",
"verbose_name_plural": "Public JWKs",
},
),
migrations.CreateModel(
name="TrustChain",
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)),
(
"sub",
models.URLField(
help_text="URL that identifies this Entity in the Federation. ",
max_length=255,
),
),
(
"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_provider",
help_text="OpenID Connect Federation entity type",
max_length=33,
),
),
("exp", models.DateTimeField()),
("iat", models.DateTimeField()),
(
"chain",
models.JSONField(
blank=True,
default=list,
help_text="A list of entity statements collected during the metadata discovery",
),
),
(
"resultant_metadata",
models.JSONField(
default=dict,
help_text="The final metadata applied with the metadata policy built over the chain",
),
),
(
"parties_involved",
models.JSONField(
blank=True,
default=list,
help_text="subjects involved in the metadata discovery",
),
),
(
"status",
models.CharField(
choices=[
("unreachable", "unreachable"),
("valid", "valid"),
("signature_failed", "signature_failed"),
("not_valid", "not_valid"),
("unknown", "unknown"),
("expired", "expired"),
],
default=False,
help_text="Status of this trust chain, on each update.",
max_length=33,
),
),
(
"status_log",
models.JSONField(blank=True, default=dict, help_text="status log"),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="If you need to disable the trust to this subject, deacticate this",
),
),
],
options={
"verbose_name": "Trust Chain",
"verbose_name_plural": "Trust Chains",
"unique_together": {("sub", "type")},
},
),
]
@@ -0,0 +1,53 @@
# Generated by Django 4.0.2 on 2022-02-13 11:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="FetchedEntityStatement",
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)),
(
"iss",
models.URLField(
help_text="URL that identifies the issuer of this statement in the Federation. ",
max_length=255,
),
),
(
"sub",
models.URLField(
help_text="URL that identifies this Entity in the Federation. ",
max_length=255,
),
),
("exp", models.DateTimeField()),
("iat", models.DateTimeField()),
(
"statement",
models.JSONField(default=dict, help_text="Entity statement"),
),
],
options={
"verbose_name": "Fetched Entity Statement",
"verbose_name_plural": "Fetched Entity Statement",
},
),
]
@@ -0,0 +1,21 @@
# Generated by Django 4.0.2 on 2022-02-13 23:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0002_fetchedentitystatement"),
]
operations = [
migrations.AddField(
model_name="federationentityconfiguration",
name="constraints",
field=models.JSONField(
default=dict,
help_text='\n{\n "naming_constraints": {\n "permitted": [\n "https://.example.com"\n ],\n "excluded": [\n "https://east.example.com"\n ]\n },\n "max_path_length": 2\n}\n',
),
),
]
@@ -0,0 +1,30 @@
# Generated by Django 4.0.2 on 2022-02-14 15:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0003_federationentityconfiguration_constraints"),
]
operations = [
migrations.AlterField(
model_name="federationentityconfiguration",
name="authority_hints",
field=models.JSONField(
blank=True,
default=list,
help_text="only required if this Entity is an intermediary or leaf.",
),
),
migrations.AlterField(
model_name="federationentityconfiguration",
name="default_exp",
field=models.PositiveIntegerField(
default=2880,
help_text="how many minutes from now() an issued statement must expire",
),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 4.0.2 on 2022-02-14 23:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"spid_cie_oidc_entity",
"0004_alter_federationentityconfiguration_authority_hints_and_more",
),
]
operations = [
migrations.AlterField(
model_name="federationentityconfiguration",
name="constraints",
field=models.JSONField(
blank=True,
default=dict,
help_text='\n{\n "naming_constraints": {\n "permitted": [\n "https://.example.com"\n ],\n "excluded": [\n "https://east.example.com"\n ]\n },\n "max_path_length": 2\n}\n',
),
),
]
@@ -0,0 +1,56 @@
# Generated by Django 4.0.2 on 2022-02-22 00:15
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
(
"spid_cie_oidc_entity",
"0005_alter_federationentityconfiguration_constraints",
),
]
operations = [
migrations.RemoveField(
model_name="trustchain",
name="resultant_metadata",
),
migrations.RemoveField(
model_name="trustchain",
name="status_log",
),
migrations.AddField(
model_name="trustchain",
name="log",
field=models.TextField(blank=True, default="", help_text="status log"),
),
migrations.AddField(
model_name="trustchain",
name="metadata",
field=models.JSONField(
blank=True,
default=dict,
help_text="The final metadata applied with the metadata policy built over the chain",
null=True,
),
),
migrations.AddField(
model_name="trustchain",
name="processing_start",
field=models.DateTimeField(
default=django.utils.timezone.localtime,
help_text="When the metadata discovery started for this Trust Chain. It should prevent concurrent processing for the same sub/type.",
),
),
migrations.AlterField(
model_name="trustchain",
name="is_active",
field=models.BooleanField(
default=True,
help_text="If you need to disable the trust to this subject, uncheck this",
),
),
]
@@ -0,0 +1,37 @@
# Generated by Django 4.0.2 on 2022-02-25 14:54
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0006_remove_trustchain_resultant_metadata_and_more"),
]
operations = [
migrations.AlterUniqueTogether(
name="trustchain",
unique_together=set(),
),
migrations.AddField(
model_name="trustchain",
name="trust_anchor",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_entity.fetchedentitystatement",
),
preserve_default=False,
),
migrations.AlterField(
model_name="trustchain",
name="iat",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterUniqueTogether(
name="trustchain",
unique_together={("sub", "type", "trust_anchor")},
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-02-25 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0007_alter_trustchain_unique_together_and_more"),
]
operations = [
migrations.AddField(
model_name="fetchedentitystatement",
name="jwt",
field=models.CharField(default="", max_length=2048),
preserve_default=False,
),
]
@@ -0,0 +1,29 @@
# Generated by Django 4.0.2 on 2022-02-26 18:26
from django.db import migrations, models
import spid_cie_oidc.entity.models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0008_fetchedentitystatement_jwt"),
]
operations = [
migrations.AddField(
model_name="trustchain",
name="trust_marks",
field=models.JSONField(
blank=True, default=list, help_text="verified trust marks"
),
),
migrations.AlterField(
model_name="federationentityconfiguration",
name="jwks",
field=models.JSONField(
default=spid_cie_oidc.entity.models.FederationEntityConfiguration._create_jwks,
help_text="a list of private keys",
),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 4.0.2 on 2022-02-26 23:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0009_trustchain_trust_marks_and_more"),
]
operations = [
migrations.AddField(
model_name="federationentityconfiguration",
name="entity_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"),
],
default="openid_relying_party",
help_text="OpenID Connect Federation entity type",
max_length=33,
),
),
]
@@ -0,0 +1,30 @@
# Generated by Django 4.0.2 on 2022-03-01 16:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0010_federationentityconfiguration_entity_type"),
]
operations = [
migrations.AlterField(
model_name="trustchain",
name="status",
field=models.CharField(
choices=[
("unreachable", "unreachable"),
("valid", "valid"),
("signature_failed", "signature_failed"),
("not_valid", "not_valid"),
("unknown", "unknown"),
("expired", "expired"),
],
default="unreachable",
help_text="Status of this trust chain, on each update.",
max_length=33,
),
),
]
@@ -0,0 +1,16 @@
# Generated by Django 4.0.2 on 2022-03-06 22:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_entity', '0011_alter_trustchain_status'),
]
operations = [
migrations.DeleteModel(
name='PublicJwk',
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-03-13 00:41
from django.db import migrations, models
import spid_cie_oidc.entity.validators
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_entity', '0012_delete_publicjwk'),
]
operations = [
migrations.AlterField(
model_name='federationentityconfiguration',
name='metadata',
field=models.JSONField(default=dict, help_text='federation_entity metadata, eg: {"federation_entity": { ... },"openid_provider": { ... },"openid_relying_party": { ... },"oauth_resource": { ... }}', validators=[spid_cie_oidc.entity.validators.validate_entity_metadata, spid_cie_oidc.entity.validators.validate_metadata_algs]),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 4.0.3 on 2022-03-20 13:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_entity', '0013_alter_federationentityconfiguration_metadata'),
]
operations = [
migrations.AlterUniqueTogether(
name='trustchain',
unique_together={('sub', 'trust_anchor')},
),
migrations.RemoveField(
model_name='trustchain',
name='type',
),
]
@@ -0,0 +1,26 @@
# Generated by Django 4.0.3 on 2022-03-20 13:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_entity', '0014_alter_trustchain_unique_together_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='trustchain',
unique_together=set(),
),
migrations.AddField(
model_name='trustchain',
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')], default='openid_provider', help_text='OpenID Connect Federation entity type', max_length=33),
),
migrations.AlterUniqueTogether(
name='trustchain',
unique_together={('sub', 'type', 'trust_anchor')},
),
]
@@ -0,0 +1,21 @@
# Generated by Django 4.0.3 on 2022-03-20 14:25
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_entity', '0015_alter_trustchain_unique_together_trustchain_type_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='trustchain',
unique_together={('sub', 'trust_anchor')},
),
migrations.RemoveField(
model_name='trustchain',
name='type',
),
]
@@ -0,0 +1,28 @@
# Generated by Django 4.0.3 on 2022-04-23 14:48
from django.db import migrations, models
import spid_cie_oidc.entity.models
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_entity', '0016_alter_trustchain_unique_together_and_more'),
]
operations = [
migrations.RemoveField(
model_name='federationentityconfiguration',
name='jwks',
),
migrations.AddField(
model_name='federationentityconfiguration',
name='jwks_core',
field=models.JSONField(default=spid_cie_oidc.entity.models.FederationEntityConfiguration._create_jwks, help_text='a list of private keys for Core ops'),
),
migrations.AddField(
model_name='federationentityconfiguration',
name='jwks_fed',
field=models.JSONField(default=spid_cie_oidc.entity.models.FederationEntityConfiguration._create_jwks, help_text='a list of private keys for Federation ops'),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.0.5 on 2022-07-03 11:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('spid_cie_oidc_entity', '0017_remove_federationentityconfiguration_jwks_and_more'),
]
operations = [
migrations.AddField(
model_name='trustchain',
name='jwks',
field=models.JSONField(default=[], help_text='jwks of this federation entity'),
preserve_default=False,
),
]
@@ -0,0 +1,33 @@
# Generated by Django 4.0.5 on 2022-10-18 09:08
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import spid_cie_oidc.entity.utils
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('spid_cie_oidc_entity', '0018_trustchain_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.entity.utils.random_token, help_text='it will be generated automatically.', max_length=255)),
('expire_at', models.DateTimeField(blank=True, null=True)),
('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,33 @@
# Generated by Django 4.2.1 on 2023-07-08 11:35
from django.db import migrations, models
import spid_cie_oidc.entity.models
import spid_cie_oidc.entity.validators
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0019_stafftoken"),
]
operations = [
migrations.AlterField(
model_name="federationentityconfiguration",
name="jwks_core",
field=models.JSONField(
blank=True,
default=spid_cie_oidc.entity.models.FederationEntityConfiguration._create_jwks,
help_text="a list of private keys for Core ops",
validators=[spid_cie_oidc.entity.validators.validate_private_jwks],
),
),
migrations.AlterField(
model_name="federationentityconfiguration",
name="jwks_fed",
field=models.JSONField(
default=spid_cie_oidc.entity.models.FederationEntityConfiguration._create_jwks,
help_text="a list of private keys for Federation ops",
validators=[spid_cie_oidc.entity.validators.validate_private_jwks],
),
),
]
@@ -0,0 +1,86 @@
# Generated by Django 4.2.1 on 2023-07-08 13:12
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
(
"spid_cie_oidc_entity",
"0020_alter_federationentityconfiguration_jwks_core_and_more",
),
]
operations = [
migrations.CreateModel(
name="FederationHistoricalKey",
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)),
(
"kid",
models.CharField(
choices=[
(0, "unspecified"),
(1, "keyCompromise"),
(2, "cACompromise"),
(3, "affiliationChanged"),
(4, "superseded"),
(5, "cessationOfOperation"),
(6, "certificateHold"),
(8, "removeFromCRL"),
(9, "privilegeWithdrawn"),
(10, "aACompromise"),
],
max_length=33,
),
),
(
"inactive_from",
models.DateTimeField(
help_text="Expired or Revocation date if revocation motivation is configured"
),
),
(
"revocation_motivation",
models.CharField(
blank=True,
choices=[
(0, "unspecified"),
(1, "keyCompromise"),
(2, "cACompromise"),
(3, "affiliationChanged"),
(4, "superseded"),
(5, "cessationOfOperation"),
(6, "certificateHold"),
(8, "removeFromCRL"),
(9, "privilegeWithdrawn"),
(10, "aACompromise"),
],
max_length=33,
),
),
(
"entity",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_entity.federationentityconfiguration",
),
),
],
options={
"verbose_name": "Federation Historical Key",
"verbose_name_plural": "Federation Historical Keys",
},
),
]
@@ -0,0 +1,79 @@
# Generated by Django 4.2.1 on 2023-07-08 14:41
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
replaces = [
("spid_cie_oidc_entity", "0021_federationhistoricalkey"),
("spid_cie_oidc_entity", "0022_federationhistoricalkey_jwk_and_more"),
("spid_cie_oidc_entity", "0023_alter_federationhistoricalkey_jwk"),
("spid_cie_oidc_entity", "0024_alter_federationhistoricalkey_kid"),
("spid_cie_oidc_entity", "0025_alter_federationhistoricalkey_jwk"),
("spid_cie_oidc_entity", "0026_alter_federationhistoricalkey_jwk"),
]
dependencies = [
(
"spid_cie_oidc_entity",
"0020_alter_federationentityconfiguration_jwks_core_and_more",
),
]
operations = [
migrations.CreateModel(
name="FederationHistoricalKey",
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)),
("kid", models.CharField(max_length=128)),
(
"inactive_from",
models.DateTimeField(
help_text="Expired or Revocation date if revocation motivation is configured"
),
),
(
"revocation_motivation",
models.CharField(
blank=True,
choices=[
(0, "unspecified"),
(1, "keyCompromise"),
(2, "cACompromise"),
(3, "affiliationChanged"),
(4, "superseded"),
(5, "cessationOfOperation"),
(6, "certificateHold"),
(8, "removeFromCRL"),
(9, "privilegeWithdrawn"),
(10, "aACompromise"),
],
max_length=33,
),
),
(
"entity",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="spid_cie_oidc_entity.federationentityconfiguration",
),
),
("jwk", models.JSONField(default=dict, help_text="private jwk")),
],
options={
"verbose_name": "Federation Historical Key",
"verbose_name_plural": "Federation Historical Keys",
},
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.2.1 on 2023-07-08 13:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0021_federationhistoricalkey"),
]
operations = [
migrations.AddField(
model_name="federationhistoricalkey",
name="jwk",
field=models.TextField(default={}, help_text="private jwk"),
preserve_default=False,
),
migrations.AlterField(
model_name="federationhistoricalkey",
name="kid",
field=models.CharField(max_length=33),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.2.1 on 2023-07-08 13:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0022_federationhistoricalkey_jwk_and_more"),
]
operations = [
migrations.AlterField(
model_name="federationhistoricalkey",
name="jwk",
field=models.TextField(default={}, help_text="private jwk"),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.2.1 on 2023-07-08 13:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0023_alter_federationhistoricalkey_jwk"),
]
operations = [
migrations.AlterField(
model_name="federationhistoricalkey",
name="kid",
field=models.CharField(max_length=128),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.2.1 on 2023-07-08 14:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0024_alter_federationhistoricalkey_kid"),
]
operations = [
migrations.AlterField(
model_name="federationhistoricalkey",
name="jwk",
field=models.JSONField(default={}, help_text="private jwk"),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.2.1 on 2023-07-08 14:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spid_cie_oidc_entity", "0025_alter_federationhistoricalkey_jwk"),
]
operations = [
migrations.AlterField(
model_name="federationhistoricalkey",
name="jwk",
field=models.JSONField(default=dict, help_text="private jwk"),
),
]
@@ -0,0 +1,35 @@
# Generated by Django 4.2.1 on 2023-07-08 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"spid_cie_oidc_entity",
"0021_federationhistoricalkey_squashed_0026_alter_federationhistoricalkey_jwk",
),
]
operations = [
migrations.AlterField(
model_name="federationhistoricalkey",
name="revocation_motivation",
field=models.CharField(
blank=True,
choices=[
("0", "unspecified"),
("1", "keyCompromise"),
("2", "cACompromise"),
("3", "affiliationChanged"),
("4", "superseded"),
("5", "cessationOfOperation"),
("6", "certificateHold"),
("8", "removeFromCRL"),
("9", "privilegeWithdrawn"),
("10", "aACompromise"),
],
max_length=33,
),
),
]
@@ -0,0 +1,23 @@
# 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_entity",
"0027_alter_federationhistoricalkey_revocation_motivation",
),
]
operations = [
migrations.AddField(
model_name="federationentityconfiguration",
name="issue_x509",
field=models.BooleanField(
default=False,
help_text="If TA/Intermediate: issues x509 in both entity statements and configuration. if leaf, issues x509 certificates in configuration.",
),
),
]
@@ -0,0 +1,38 @@
# 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_entity", "0028_federationentityconfiguration_issue_x509"),
]
operations = [
migrations.AlterField(
model_name="federationentityconfiguration",
name="entity_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="federationentityconfiguration",
name="issue_x509",
field=models.BooleanField(
default=False,
help_text="Issues x509 certificates in the Entity Configuration.",
),
),
]
@@ -0,0 +1,19 @@
# 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_entity",
"0029_alter_federationentityconfiguration_entity_type_and_more",
),
]
operations = [
migrations.RemoveField(
model_name="federationentityconfiguration",
name="issue_x509",
),
]
@@ -0,0 +1,34 @@
# 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_entity",
"0030_remove_federationentityconfiguration_issue_x509",
),
]
operations = [
migrations.AlterField(
model_name="federationentityconfiguration",
name="entity_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,
),
),
]
@@ -0,0 +1,20 @@
# Generated by Django 4.2.3 on 2023-12-13 14:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"spid_cie_oidc_entity",
"0031_alter_federationentityconfiguration_entity_type",
),
]
operations = [
migrations.AlterField(
model_name="fetchedentitystatement",
name="jwt",
field=models.TextField(blank=False, null=False),
),
]
@@ -0,0 +1,18 @@
# 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_entity', '0032_alter_fetchedentitystatement_jwt'),
]
operations = [
migrations.AlterField(
model_name='federationentityconfiguration',
name='entity_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),
),
]
@@ -0,0 +1,550 @@
import json
import logging
from typing import Union
import uuid
from django.contrib.auth import get_user_model
from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext as _
from spid_cie_oidc.entity.abstract_models import TimeStampedModel
from cryptojwt.jwk.jwk import key_from_jwk_dict
from spid_cie_oidc.entity.jwks import (
create_jwk,
private_pem_from_jwk,
public_pem_from_jwk,
)
from spid_cie_oidc.entity.jwtse import create_jws
from spid_cie_oidc.entity.settings import (
ENTITY_STATUS,
ENTITY_TYPE_LEAFS,
ENTITY_TYPES,
FEDERATION_DEFAULT_EXP
)
from spid_cie_oidc.entity.statements import EntityConfiguration
from spid_cie_oidc.entity.utils import exp_from_now, iat_now, random_token
from spid_cie_oidc.entity.validators import (
validate_entity_metadata,
validate_metadata_algs,
validate_private_jwks
)
from .jwk_x5c import update_jwks_with_x5c
from .jwks import public_jwk_from_private_jwk
logger = logging.getLogger(__name__)
def is_leaf(statement_metadata):
for _typ in ENTITY_TYPE_LEAFS:
if _typ in statement_metadata:
return True # pragma: no cover
class FederationEntityConfiguration(TimeStampedModel):
"""
Federation Authority configuration.
"""
def _create_jwks():
return [create_jwk()]
uuid = models.UUIDField(
blank=False, null=False, default=uuid.uuid4, unique=True, editable=False
)
sub = models.URLField(
max_length=255,
blank=False,
null=False,
help_text=_(
"URL that identifies this Entity in the Federation. "
"This value and iss are the same in the Entity Configuration."
),
)
default_exp = models.PositiveIntegerField(
default=FEDERATION_DEFAULT_EXP,
help_text=_("how many minutes from now() an issued statement must expire"),
)
default_signature_alg = models.CharField(
max_length=16,
default=(
"ES256" if (
hasattr(settings, "PRIVATE_KEY_TYPE")
and getattr(settings, "PRIVATE_KEY_TYPE") == "EC"
) else "RS256"
),
blank=False,
null=False,
help_text=_("default signature algorithm, eg: RS256"),
)
authority_hints = models.JSONField(
blank=True,
null=False,
help_text=_("only required if this Entity is an intermediary or leaf."),
default=list,
)
jwks_fed = models.JSONField(
blank=False,
null=False,
help_text=_("a list of private keys for Federation ops"),
default=_create_jwks,
validators = [validate_private_jwks],
)
jwks_core = models.JSONField(
blank=True,
null=False,
help_text=_("a list of private keys for Core ops"),
default=_create_jwks,
validators = [validate_private_jwks],
)
trust_marks = models.JSONField(
blank=True,
help_text=_("which trust marks MUST be exposed in its entity configuration"),
default=list,
)
trust_mark_issuers = models.JSONField(
blank=True,
help_text=_(
"Only usable for Trust Anchors and intermediates. "
"Which issuers are allowed to issue trust marks for the descendants. "
'Example: {"https://www.spid.gov.it/certification/rp": '
'["https://registry.spid.agid.gov.it", "https://intermediary.spid.it"],'
'"https://sgd.aa.it/onboarding": ["https://sgd.aa.it", ]}'
),
default=dict,
)
entity_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"),
)
metadata = models.JSONField(
blank=False,
null=False,
help_text=_(
"federation_entity metadata, eg: "
'{"federation_entity": { ... },'
'"openid_provider": { ... },'
'"openid_relying_party": { ... },'
'"oauth_resource": { ... }'
"}"
),
default=dict,
validators=[
validate_entity_metadata,
validate_metadata_algs
],
)
constraints = models.JSONField(
blank=True,
help_text=_(
"""
{
"naming_constraints": {
"permitted": [
"https://.example.com"
],
"excluded": [
"https://east.example.com"
]
},
"max_path_length": 2
}
"""
),
default=dict,
# TODO
# validators=[validate_entity_metadata,]
)
is_active = models.BooleanField(
default=False,
help_text=_(
"If this configuration is active. "
"At least one configuration must be active"
),
)
class Meta:
verbose_name = "Federation Entity Configuration"
verbose_name_plural = "Federation Entity Configurations"
@classmethod
def get_active_conf(cls):
"""
returns the first available active acsia engine configuration found
"""
return cls.objects.filter(is_active=True).first()
@property
def public_jwks(self):
"""
Export public JWKs from private jwks_fed.
Uses public_jwk_from_private_jwk to avoid SerializationNotPossible
when serialize_rsa_key+RSAKey.thumbprint() fails with cryptojwt.
"""
res = []
for i in self.jwks_fed:
skey = public_jwk_from_private_jwk(i)
res.append(skey)
return res
@property
def pems_as_dict(self):
res = {}
for i in self.jwks_fed:
res[i["kid"]] = {
"private": private_pem_from_jwk(i),
"public": public_pem_from_jwk(i),
}
return res
@property
def pems_as_json(self):
return json.dumps(self.pems_as_dict, indent=2)
@property
def kids(self) -> list:
return [i["kid"] for i in self.jwks_fed]
@property
def type(self) -> list:
return [i for i in self.metadata.keys()]
@property
def is_leaf(self):
return is_leaf(self.metadata)
@property
def entity_configuration_as_dict(self):
conf = {
"exp": exp_from_now(self.default_exp),
"iat": iat_now(),
"iss": self.sub,
"sub": self.sub,
"jwks": {"keys": self.public_jwks},
"metadata": self.metadata,
}
if self.trust_mark_issuers:
conf["trust_mark_issuers"] = self.trust_mark_issuers
if self.trust_marks:
conf["trust_marks"] = self.trust_marks
if self.constraints:
conf["constraints"] = self.constraints
if self.authority_hints:
conf["authority_hints"] = self.authority_hints
elif self.is_leaf: # pragma: no cover
_msg = f"Entity {self.sub} is a leaf and requires authority_hints valued"
logger.error(_msg)
if hasattr(settings, "X509_COMMON_NAME"):
conf['jwks'] = {"keys": update_jwks_with_x5c(
jwks=self.public_jwks,
private_key=key_from_jwk_dict(self.jwks_fed[0]).private_key(),
subject=self.sub,
is_ca_or_int=True
)}
return conf
@property
def entity_configuration_as_json(self):
return json.dumps(self.entity_configuration_as_dict)
@property
def entity_configuration_as_jws(self, **kwargs):
return create_jws(
self.entity_configuration_as_dict,
self.jwks_fed[0],
alg=self.default_signature_alg,
typ="entity-statement+jwt",
**kwargs,
)
@property
def fetch_endpoint(self) -> Union[str, None]:
metadata = self.entity_configuration_as_dict.get('metadata', {})
if 'federation_entity' in metadata:
return metadata['federation_entity'].get("federation_fetch_endpoint", None)
def set_jwks_as_array(self):
for i in ('jwks_fed','jwks_core'):
value = getattr(self, i)
if not isinstance(value, list):
setattr(self, i, [value])
def save(self, *args, **kwargs):
self.set_jwks_as_array()
super().save(*args, **kwargs)
def __str__(self):
return "{} [{}]".format(
self.sub, "active" if self.is_active else "--"
)
class FetchedEntityStatement(TimeStampedModel):
"""
Entity Statement acquired by a third party
"""
iss = models.URLField(
max_length=255,
blank=False,
help_text=_(
"URL that identifies the issuer of this statement in the Federation. "
),
)
sub = models.URLField(
max_length=255,
blank=False,
help_text=_("URL that identifies this Entity in the Federation. "),
)
exp = models.DateTimeField()
iat = models.DateTimeField()
statement = models.JSONField(
blank=False, null=False, help_text=_("Entity statement"), default=dict
)
jwt = models.TextField(null=False, blank=False)
class Meta:
verbose_name = "Fetched Entity Statement"
verbose_name_plural = "Fetched Entity Statement"
def get_entity_configuration_as_obj(self):
return EntityConfiguration(self.jwt)
@property
def is_expired(self):
return self.exp <= timezone.localtime()
def __str__(self):
return f"{self.sub} issued by {self.iss}"
class TrustChain(TimeStampedModel):
"""
Federation Trust Chain
"""
sub = models.URLField(
max_length=255,
blank=False,
help_text=_("URL that identifies this Entity in the Federation. "),
)
trust_anchor = models.ForeignKey(FetchedEntityStatement, on_delete=models.CASCADE)
exp = models.DateTimeField()
iat = models.DateTimeField(auto_now_add=True)
chain = models.JSONField(
blank=True,
help_text=_(
"A list of entity statements collected during the metadata discovery"
),
default=list,
)
jwks = models.JSONField(
blank=False,
null=False,
help_text=_("jwks of this federation entity")
)
metadata = models.JSONField(
blank=True,
null=True,
help_text=_(
"The final metadata applied with the metadata policy built over the chain"
),
default=dict,
)
trust_marks = models.JSONField(
blank=True, help_text=_("verified trust marks"), default=list
)
parties_involved = models.JSONField(
blank=True,
help_text=_("subjects involved in the metadata discovery"),
default=list,
)
status = models.CharField(
max_length=33,
default="unreachable",
help_text=_("Status of this trust chain, on each update."),
choices=[(i, i) for i in list(ENTITY_STATUS.keys())],
)
log = models.TextField(blank=True, help_text=_("status log"), default="")
processing_start = models.DateTimeField(
help_text=_(
"When the metadata discovery started for this Trust Chain. "
"It should prevent concurrent processing for the same sub/type."
),
default=timezone.localtime,
)
is_active = models.BooleanField(
default=True,
help_text=_("If you need to disable the trust to this subject, uncheck this"),
)
class Meta:
verbose_name = "Trust Chain"
verbose_name_plural = "Trust Chains"
unique_together = ("sub", "trust_anchor")
@property
def subject(self):
return self.sub # pragma: no cover
@property
def is_expired(self):
return self.exp <= timezone.localtime()
@property
def iat_as_timestamp(self):
return int(self.iat.timestamp())
@property
def exp_as_timestamp(self):
return int(self.exp.timestamp())
@property
def is_valid(self):
return self.is_active and ENTITY_STATUS[self.status]
def __str__(self):
return "{} [{}] [{}]".format(
self.sub, self.trust_anchor, self.is_valid
)
class StaffToken(TimeStampedModel):
"""
Token provisioned to staffs operators for protected resources
"""
user = models.ForeignKey(
get_user_model(),
on_delete=models.CASCADE,
help_text=_("The user responsible of thi token"),
)
token = models.CharField(
max_length=255,
blank=False,
null=False,
default = random_token,
help_text=_("it will be generated automatically."),
)
expire_at = models.DateTimeField(blank=True, null=True)
is_active = models.BooleanField(
default=True,
blank=False,
null=False
)
class Meta:
verbose_name = "Staff Token"
verbose_name_plural = "Staff Tokens"
@property
def is_valid(self):
if self.is_active and not self.expire_at:
return True
elif self.is_active and self.expire_at > timezone.localtime():
return True
else:
return False
def __str__(self):
return f"{self.user} {self.is_active}"
class FederationHistoricalKey(TimeStampedModel):
"""
https://openid.net/specs/openid-connect-federation-1_0.html#name-federation-historical-keys-
"""
REVOCATION_REASONS_MAP = (
("0", 'unspecified'),
("1", 'keyCompromise'),
("2", 'cACompromise'),
("3", 'affiliationChanged'),
("4", 'superseded'),
("5", 'cessationOfOperation'),
("6", 'certificateHold'),
# 7 is unused in rfc5280
("8", 'removeFromCRL'),
("9", 'privilegeWithdrawn'),
("10", 'aACompromise')
)
# REVOCATION_REASONS_CODES = {k:k for k in REVOCATION_REASONS_MAP.keys()}
entity = models.ForeignKey(FederationEntityConfiguration, on_delete=models.CASCADE)
kid = models.CharField(
blank=False, null=False, max_length=128
)
inactive_from = models.DateTimeField(help_text=_(
"Expired or Revocation date if revocation motivation is configured"
))
revocation_motivation = models.CharField(
blank=True, null=False, max_length=33, choices=REVOCATION_REASONS_MAP
)
jwk = models.JSONField(help_text=_("private jwk"), default=dict)
class Meta:
verbose_name = "Federation Historical Key"
verbose_name_plural = "Federation Historical Keys"
@property
def as_dict(self):
if not self.jwk:
return {}
kdict = public_jwk_from_private_jwk(self.jwk)
if self.revocation_motivation:
kdict["revoked"] = {
"revoked_at": int(self.inactive_from.timestamp()),
"reason": dict(self.REVOCATION_REASONS_MAP)[self.revocation_motivation],
"reason_code": self.revocation_motivation
}
else:
kdict["exp"] = int(self.inactive_from.timestamp())
return kdict
@property
def as_json(self):
return json.dumps(self.as_dict)
def save(self, *args, **kwargs):
if self.kid:
for jwk in self.entity.jwks_fed:
if not jwk.get('kid', None):
continue
elif self.kid == jwk['kid']:
_indx = self.entity.jwks_fed.index(jwk)
self.jwk = self.entity.jwks_fed.pop(_indx)
self.entity.save()
break
super().save(*args, **kwargs)
def __str__(self):
return f"{self.entity} {self.kid}"
def get_first_self_trust_anchor(
sub: str = None,
) -> Union[FederationEntityConfiguration, None]:
"""
get the first available Trust Anchor that represent self
as a qualified issuer
"""
lk = dict(metadata__federation_entity__isnull=False, is_active=True)
if sub:
lk["sub"] = sub
return FederationEntityConfiguration.objects.filter(**lk).first()
@@ -0,0 +1,355 @@
__author__ = "Roland Hedberg"
__license__ = "Apache 2.0"
__version__ = ""
import logging
logger = logging.getLogger(__name__)
class PolicyError(Exception):
pass
def combine_subset_of(s1, s2): # pragma: no cover
return list(set(s1).intersection(set(s2)))
def combine_superset_of(s1, s2): # pragma: no cover
return list(set(s1).intersection(set(s2)))
def combine_one_of(s1, s2): # pragma: no cover
return list(set(s1).intersection(set(s2)))
def combine_add(s1, s2): # pragma: no cover
if isinstance(s1, list):
set1 = set(s1)
else:
set1 = {s1}
if isinstance(s2, list):
set2 = set(s2)
else:
set2 = {s2}
return list(set1.union(set2))
POLICY_FUNCTIONS = {
"subset_of",
"superset_of",
"one_of",
"add",
"value",
"default",
"essential",
}
OP2FUNC = {
"subset_of": combine_subset_of,
"superset_of": combine_superset_of,
"one_of": combine_one_of,
"add": combine_add,
}
def do_sub_one_super_add(superior, child, policy): # pragma: no cover
if policy in superior and policy in child:
comb = OP2FUNC[policy](superior[policy], child[policy])
if comb:
return comb
else:
raise PolicyError("Value sets doesn't overlap")
elif policy in superior:
return superior[policy]
elif policy in child:
return child[policy]
def do_value(superior, child, policy): # pragma: no cover
if policy in superior and policy in child:
if superior[policy] == child[policy]:
return superior[policy]
else:
raise PolicyError("Not allowed to combine values")
elif policy in superior:
return superior[policy]
elif policy in child:
return child[policy]
def do_default(superior, child, policy): # pragma: no cover
# A child's default can not override a superiors
if policy in superior and policy in child:
if superior["default"] == child["default"]:
return superior["default"]
else:
raise PolicyError("Not allowed to change default")
elif policy in superior:
return superior[policy]
elif policy in child:
return child[policy]
def do_essential(superior, child, policy): # pragma: no cover
# essential: an child can make it True if a superior has states False
# but not the other way around
if policy in superior and policy in child:
if not superior[policy] and child["essential"]:
return True
else:
return superior[policy]
elif policy in superior:
return superior[policy]
elif policy in child: # Not in superior is the same as essential=True
return True
DO_POLICY = {
"superset_of": do_sub_one_super_add,
"subset_of": do_sub_one_super_add,
"one_of": do_sub_one_super_add,
"add": do_sub_one_super_add,
"value": do_value,
"default": do_default,
"essential": do_essential,
}
def combine_claim_policy(superior, child): # noqa: C901 # pragma: no cover
"""
Combine policy rules.
Applying the child policy can only make the combined policy more restrictive.
:param superior: Superior policy
:param child: Intermediates policy
"""
# weed out everything I don't recognize
superior_set = set(superior).intersection(POLICY_FUNCTIONS)
child_set = set(child).intersection(POLICY_FUNCTIONS)
if "value" in superior_set: # An exact value can not be restricted.
if child_set:
if "essential" in child_set:
if len(child_set) == 1:
return {"value": superior["value"], "essential": child["essential"]}
else:
raise PolicyError(
"value can only be combined with essential, not {}".format(
child_set
)
)
elif "value" in child_set:
if child["value"] != superior["value"]: # Not OK
raise PolicyError("Child can not set another value then superior")
else:
return superior
else:
raise PolicyError(
"Not allowed combination of policies: {} + {}".format(
superior, child
)
)
return superior
else:
if "essential" in superior_set and "essential" in child_set:
# can only go from False to True
if (
superior["essential"] != child["essential"]
and child["essential"] is False
):
raise PolicyError("Essential can not go from True to False")
comb_policy = superior_set.union(child_set)
if "one_of" in comb_policy:
if "subset_of" in comb_policy or "superset_of" in comb_policy:
raise PolicyError(
"one_of can not be combined with subset_of/superset_of"
)
rule = {}
for policy in comb_policy:
rule[policy] = DO_POLICY[policy](superior, child, policy)
if comb_policy == {"superset_of", "subset_of"}:
# make sure the subset_of is a superset of superset_of.
if set(rule["superset_of"]).difference(set(rule["subset_of"])):
raise PolicyError("superset_of not a super set of subset_of")
elif comb_policy == {"superset_of", "subset_of", "default"}:
# make sure the subset_of is a superset of superset_of.
if set(rule["superset_of"]).difference(set(rule["subset_of"])):
raise PolicyError("superset_of not a super set of subset_of")
if set(rule["default"]).difference(set(rule["subset_of"])):
raise PolicyError("default not a sub set of subset_of")
if set(rule["superset_of"]).difference(set(rule["default"])):
raise PolicyError("default not a super set of subset_of")
elif comb_policy == {"subset_of", "default"}:
if set(rule["default"]).difference(set(rule["subset_of"])):
raise PolicyError("default not a sub set of subset_of")
elif comb_policy == {"superset_of", "default"}:
if set(rule["superset_of"]).difference(set(rule["default"])):
raise PolicyError("default not a super set of subset_of")
elif comb_policy == {"one_of", "default"}:
if isinstance(rule["default"], list):
if set(rule["default"]).difference(set(rule["one_of"])):
raise PolicyError("default not a super set of one_of")
else:
if {rule["default"]}.difference(set(rule["one_of"])):
raise PolicyError("default not a super set of one_of")
return rule
def combine_policy(superior, child):
res = {}
sup_set = set(superior.keys())
chi_set = set(child.keys())
for claim in set(sup_set).intersection(chi_set):
res[claim] = combine_claim_policy(superior[claim], child[claim])
for claim in sup_set.difference(chi_set):
res[claim] = superior[claim]
for claim in chi_set.difference(sup_set):
res[claim] = child[claim]
return res
def gather_policies(chain, entity_type):
"""
Gather and combine all the metadata policies that are defined in the trust chain
:param chain: A list of Entity Statements
:return: The combined metadata policy
"""
try:
combined_policy = chain[0]["metadata_policy"][entity_type]
except KeyError:
combined_policy = {}
for es in chain[1:]:
try:
child = es["metadata_policy"][entity_type]
except KeyError:
pass
else:
combined_policy = combine_policy(combined_policy, child)
return combined_policy
def union(val1, val2):
if isinstance(val1, list):
base = set(val1)
else:
base = {val1}
if isinstance(val2, list):
ext = set(val2)
else:
ext = {val2}
return base.union(ext)
def apply_policy(metadata, policy):
"""
Apply a metadata policy to a metadata statement.
The order is value, add, default and then the checks subset_of/superset_of and one_of
:param metadata: A metadata statement
:param policy: A metadata policy
:return: A metadata statement that adheres to a metadata policy
"""
metadata_set = set(metadata.keys())
policy_set = set(policy.keys())
# Metadata claims that there exists a policy for
for claim in metadata_set.intersection(policy_set):
if "value" in policy[claim]: # value overrides everything
metadata[claim] = policy[claim]["value"]
else:
if "one_of" in policy[claim]:
# The is for claims that can have only one value
if isinstance(metadata[claim], list): # Should not be but ...
_claim = [
c for c in metadata[claim] if c in policy[claim]["one_of"]
]
if _claim:
metadata[claim] = _claim[0]
else:
raise PolicyError(
"{}: None of {} among {}".format(
claim, metadata[claim], policy[claim]["one_of"]
)
)
else:
if metadata[claim] in policy[claim]["one_of"]:
pass
else:
raise PolicyError(
"{} not among {}".format(
metadata[claim], policy[claim]["one_of"]
)
)
else:
# The following is for claims that can have lists of values
if "add" in policy[claim]:
metadata[claim] = list(union(metadata[claim], policy[claim]["add"]))
if "subset_of" in policy[claim]:
_val = set(policy[claim]["subset_of"]).intersection(
set(metadata[claim])
)
if _val:
metadata[claim] = list(_val)
else:
raise PolicyError(
"{} not subset of {}".format(
metadata[claim], policy[claim]["subset_of"]
)
)
if "superset_of" in policy[claim]:
if set(policy[claim]["superset_of"]).difference(
set(metadata[claim])
):
raise PolicyError(
"{} not superset of {}".format(
metadata[claim], policy[claim]["superset_of"]
)
)
else:
pass
# In policy but not in metadata
for claim in policy_set.difference(metadata_set):
if "value" in policy[claim]:
metadata[claim] = policy[claim]["value"]
elif "add" in policy[claim]:
metadata[claim] = policy[claim]["add"]
elif "default" in policy[claim]:
metadata[claim] = policy[claim]["default"]
if claim not in metadata:
if "essential" in policy[claim] and policy[claim]["essential"]:
raise PolicyError("Essential claim '{}' missing".format(claim))
# All that are in metadata but not in policy should just remain
return metadata
def diff2policy(new, old):
res = {}
for claim in set(new).intersection(set(old)):
if new[claim] == old[claim]:
continue
else:
res[claim] = {"value": new[claim]}
for claim in set(new).difference(set(old)):
if claim in ["contacts"]:
res[claim] = {"add": new[claim]}
else:
res[claim] = {"value": new[claim]}
return res
@@ -0,0 +1,12 @@
from typing import List
from pydantic import BaseModel, HttpUrl, EmailStr
class FAMetadata(BaseModel):
contacts: List[EmailStr]
federation_fetch_endpoint: HttpUrl
federation_resolve_endpoint: HttpUrl
federation_list_endpoint: HttpUrl
homepage_uri: HttpUrl
organization_name: str
@@ -0,0 +1,72 @@
from typing import List, Literal, Optional
from pydantic import BaseModel, validator
class Jwk(BaseModel):
kid: str # Base64url-encoded thumbprint string
kty: Literal["EC", "RSA"]
# TODO: verify if is optional
alg: Optional[
Literal[
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
"PS256",
"PS384",
"PS512",
"RSA-OAEP",
"RSA-OAEP-256",
"ECDH-ES",
"ECDH-ES+A128KW",
"ECDH-ES+A256KW"
]
]
use: Optional[Literal["sig", "enc"]]
n: Optional[str] # Base64urlUInt-encoded
e: Optional[str] # Base64urlUInt-encoded
def check_value_for_rsa(value, name, values):
if "EC" == values.get("kty") and value:
raise ValueError(f"{name} must be present only for kty = RSA")
def check_value_for_ec(value, name, values):
if "RSA" == values.get("kty") and value:
raise ValueError(f"{name} must be present only for kty = EC")
@validator("n")
def validate_n(cls, n_value, values):
cls.check_value_for_rsa(n_value, "n", values)
@validator("e")
def validate_e(cls, e_value, values):
cls.check_value_for_rsa(e_value, "e", values)
class JwkCie(Jwk):
x: Optional[str] # Base64url-encoded
y: Optional[str] # Base64url-encoded
crv: Optional[Literal["P-256", "P-384", "P-521"]]
@validator("x")
def validate_x(cls, x_value, values):
cls.check_value_for_ec(x_value, "x", values)
@validator("y")
def validate_y(cls, y_value, values):
cls.check_value_for_ec(y_value, "y", values)
@validator("crv")
def validate_crv(cls, crv_value, values):
cls.check_value_for_ec(crv_value, "crv", values)
class JwksCie(BaseModel):
keys: List[JwkCie]
class JwksSpid(BaseModel):
keys: List[Jwk]
@@ -0,0 +1,138 @@
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, HttpUrl, root_validator
from .jwks import JwksCie
class ScopeSupported(str, Enum):
openid = "openid"
offline_access = "offline_access"
profile = "profile"
email = "email"
class ResponseModesSupported(str, Enum):
form_post = ("form_post",)
query = "query"
class GrantTypeSupported(str, Enum):
refresh_token = "refresh_token" # nosec - B105
authorization_code = "authorization_code"
class AcrValuesSupported(str, Enum):
spid_l1 = "https://www.spid.gov.it/SpidL1"
spid_l2 = "https://www.spid.gov.it/SpidL2"
spid_l3 = "https://www.spid.gov.it/SpidL3"
class SigningAlgValuesSupported(str, Enum):
es256 = "ES256"
es384 = "ES384"
es512 = "ES512"
rs256 = "RS256"
rs384 = "RS384"
rs512 = "RS512"
# TODO: to be confirmed
# ps256 = "PS256"
# ps384 = "PS384"
# ps512 = "PS512"
class EncryptionAlgValuesSupported(str, Enum):
rsa_oaep = "RSA-OAEP"
ras_oaep_256 = "RSA-OAEP-256"
class EncryptionEncValuesSupported(str, Enum):
a128cbc_hs256 = "A128CBC-HS256"
a192cbc_hs384 = "A192CBC-HS384"
a256cbc_hs512 = "A256CBC-HS512"
a128gcm = "A128GCM"
a192gcm = "A192GCM"
a256gcm = "A256GCM"
rsa_oaep_256 = "RSA-OAEP-256"
class ClaimsSupported(str, Enum):
given_name = "given_name"
family_name = "family_name"
email = "email"
email_verified = "email_verified"
gender = "gender"
birthdate = "birthdate"
phone_number = "phone_number"
phone_number_verified = "phone_number_verified"
place_of_birth = "place_of_birth"
address = "address"
document_details = "document_details"
https_attributes_eid_gov_it_e_delivery_service = (
"https://attributes.eid.gov.it/e_delivery_service"
)
https_attributes_eid_gov_it_fiscal_number = (
"https://attributes.eid.gov.it/fiscal_number"
)
https_attributes_eid_gov_it_idANPR = "https://attributes.eid.gov.it/idANPR"
https_attributes_eid_gov_it_physical_phone_number = (
"https://attributes.eid.gov.it/physical_phone_number"
)
class OPMetadata(BaseModel):
organization_name: str
issuer: HttpUrl
authorization_endpoint: HttpUrl
token_endpoint: HttpUrl
userinfo_endpoint: HttpUrl
introspection_endpoint: HttpUrl
revocation_endpoint: HttpUrl
token_endpoint_auth_signing_alg_values_supported: List[SigningAlgValuesSupported]
id_token_encryption_alg_values_supported: List[EncryptionAlgValuesSupported]
id_token_encryption_enc_values_supported: List[EncryptionEncValuesSupported]
userinfo_encryption_alg_values_supported: List[EncryptionAlgValuesSupported]
userinfo_encryption_enc_values_supported: List[EncryptionEncValuesSupported]
request_object_encryption_alg_values_supported: List[EncryptionAlgValuesSupported]
request_object_encryption_enc_values_supported: List[EncryptionEncValuesSupported]
id_token_signing_alg_values_supported: List[SigningAlgValuesSupported]
userinfo_signing_alg_values_supported: List[SigningAlgValuesSupported]
request_object_signing_alg_values_supported: List[SigningAlgValuesSupported]
token_endpoint_auth_methods_supported = ["private_key_jwt"]
subject_types_supported = ["pairwise"]
request_parameter_supported = True
acr_values_supported: List[AcrValuesSupported]
signed_jwks_uri: Optional[HttpUrl]
jwks_uri: Optional[HttpUrl]
jwks: Optional[JwksCie]
@root_validator(pre=False)
def validate(cls, values):
jwks_there = False
for i in ("jwks_uri", "jwks", "signed_jwks_uri"):
if values.get(i):
jwks_there = True
break
if not jwks_there:
raise ValueError(
"one of signed_jwks_uri or jwks_uri or jwks must be set"
)
return values
class OPMetadataCie(OPMetadata):
scopes_supported: List[ScopeSupported]
response_types_supported = ["code"]
response_modes_supported: List[ResponseModesSupported]
grant_types_supported: List[GrantTypeSupported]
claims_supported: List[ClaimsSupported]
claims_parameter_supported = True
tls_client_certificate_bound_access_tokens = True
authorization_response_iss_parameter_supported = True
class OPMetadataSpid(OPMetadata):
op_name: Optional[str]
op_uri: Optional[str]

Some files were not shown because too many files have changed in this diff Show More