Het landschap van de Italiaanse digitale identiteit

Het Italiaanse ecosysteem voor digitale identiteit is met drie een van de meest complexe in Europa belangrijkste systemen die naast elkaar bestaan en convergeren: SPID (Openbaar identiteitssysteem digitaal), CIE (Elektronische identiteitskaart) en, voor betalingen, betaalPA. Vanaf 2023 zal de transitie naar OpenID Connect als protocol unified heeft de integratie voor ontwikkelaars aanzienlijk vereenvoudigd.

Als ontwikkelaar of architect die overheidsauthenticatie in een applicatie moet integreren, krijgt u te maken met verschillende technische keuzes met aanzienlijke gevolgen. Dit artikel begeleidt u bij de AGID-onboarding tot de concrete implementatie, waarbij SAML 2.0 (het historische SPID-protocol) wordt vergeleken met OpenID Connect Federatie (de toekomst van zowel SPID als CIE), en laat zien hoe pagoPA voor betalingen kan worden geïntegreerd.

Wat je gaat leren

  • SPID-architectuur: Identity Provider, Service Provider, SAML 2.0 en beveiligingsniveaus
  • CIE-architectuur: NFC-chip, PIN, CIE-ID-backend en authenticatiemodus
  • OpenID Connect voor SPID en CIE: federatie, tokens, claims en scopes
  • AGID-onboardingproces: technische en procedurele vereisten
  • Officiële SDK's: bibliotheken voor 5 programmeertalen
  • Praktische implementatie: Python en TypeScript met volledige voorbeelden
  • pagoPA: betalingsintegratie in PA-diensten
  • Test- en faseringsomgevingen voor ontwikkeling en validatie

SPID: Openbaar digitaal identiteitssysteem

SPID is het Italiaanse nationale digitale identiteitssysteem, opgericht door Wetgevend besluit 82/2005 (CAD) en gereguleerd door AgID. Hiermee kunnen burgers zich authenticeren op elke PA-dienst (en vele private) met één paar inloggegevens, beheerd door een van de Identiteitsprovider (IdP) geaccrediteerd (Aruba, Infocert, Namirial, Poste, Register, Sielte, SpidItalia, Tim, Intesa).

SPID voorspelt 3 beveiligingsniveaus:

  • Niveau 1: authenticatie met gebruikersnaam en wachtwoord. Geschikt voor diensten met een laag risico.
  • Niveau 2: gebruikersnaam/wachtwoord + OTP (SMS of app). Het meest gebruikte niveau voor PA-diensten. Vereist een tweede authenticatiefactor (2FA).
  • Niveau 3: authenticatie met digitaal certificaat of smartcard. Voor hoogwaardige diensten risico (notariële akten, gekwalificeerde digitale handtekening).

CIE: Elektronische Identiteitskaart

De CIE 3.0, uitgegeven door het State Printing and Mint Institute, bevat een NFC-chip met certificaten X.509 digitale apparaten die sterke authenticatie mogelijk maken. Het CIE-ID-systeem maakt online authenticatie mogelijk via:

  • Smartphone met NFC: de gebruiker brengt de CIE dicht bij de smartphone en voert de pincode in. De CIE ID-app genereert een digitaal ondertekende authenticatiebewering.
  • Desktop met NFC-lezer: via de CIE ID-software van het Ministerie van Binnenlandse Zaken.
  • Desktop zonder NFC: authenticatie via QR-code gescand met de CIE ID-app.

Vanuit ontwikkelaarsperspectief delen CIE en SPID nu hetzelfde OpenID Connect (OIDC)-protocol, met verschillen voornamelijk in de registratie en metadata van de Identity Provider.

OpenID Connect voor SPID en CIE: het Unified Protocol

AgID publiceerde de OpenID Connect Technische regels voor SPID en CIE, beschikbaar op docs.italia.it. Deze regels definiëren a OpenID Connect-federatie gebaseerd op de OIDC Federation 1.0-standaard (IETF-concept), waarbij:

  • AgID het is er Vertrouwen anker: het hoofdknooppunt van de federatie, de bron van waarheid voor vertrouwen tussen alle deelnemers.
  • Identiteitsproviders (hetzelfde als SPID) zijn Bladknooppunten registreren bij AgID.
  • I Vertrouwende partij (diensten die SPID/CIE willen gebruiken) registreren door te publiceren een Entiteitsconfiguratie (een JWT ondertekend met uw openbare sleutels).
# Implementazione OpenID Connect per SPID/CIE in Python
# Usa la libreria spid-cie-oidc-django o implementazione custom

import httpx
import jwt
import json
import secrets
import hashlib
import base64
from datetime import datetime, timedelta
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend

class SPIDCIEOIDCClient:
    """
    Client OIDC per integrazione SPID/CIE.
    Implementa il flow Authorization Code con PKCE (obbligatorio per SPID/CIE OIDC).
    """

    def __init__(
        self,
        client_id: str,       # URI del Relying Party (il tuo servizio)
        redirect_uri: str,    # URI di callback
        private_key_path: str, # Chiave privata RSA/EC per firma JWT
        trust_anchor: str = "https://registry.agid.gov.it"
    ):
        self.client_id = client_id
        self.redirect_uri = redirect_uri
        self.trust_anchor = trust_anchor

        # Carica chiave privata per firma
        with open(private_key_path, "rb") as f:
            self.private_key = serialization.load_pem_private_key(
                f.read(), password=None, backend=default_backend()
            )

    def generate_pkce(self) -> tuple[str, str]:
        """
        Genera code_verifier e code_challenge per PKCE.
        PKCE è OBBLIGATORIO nelle specifiche SPID/CIE OIDC.
        """
        # code_verifier: stringa casuale di 43-128 caratteri
        code_verifier = secrets.token_urlsafe(64)

        # code_challenge = BASE64URL(SHA256(code_verifier))
        code_challenge = base64.urlsafe_b64encode(
            hashlib.sha256(code_verifier.encode()).digest()
        ).rstrip(b"=").decode()

        return code_verifier, code_challenge

    def build_authorization_url(
        self,
        idp_authorization_endpoint: str,
        scope: list[str] = None,
        acr_values: str = "https://www.spid.gov.it/SpidL2",  # Livello 2 default
        state: str = None,
        nonce: str = None,
        ui_locales: str = "it",
        claims: dict = None
    ) -> tuple[str, dict]:
        """
        Costruisce l'URL di autorizzazione per SPID/CIE OIDC.
        Ritorna (authorization_url, session_data) dove session_data va salvato in sessione.
        """
        if scope is None:
            scope = ["openid", "profile"]

        if state is None:
            state = secrets.token_urlsafe(32)

        if nonce is None:
            nonce = secrets.token_urlsafe(32)

        code_verifier, code_challenge = self.generate_pkce()

        # Request Object: JWT firmato con claims della request
        # Obbligatorio in SPID/CIE OIDC per sicurezza end-to-end
        request_object_claims = {
            "iss": self.client_id,
            "aud": idp_authorization_endpoint,
            "iat": int(datetime.utcnow().timestamp()),
            "exp": int((datetime.utcnow() + timedelta(minutes=5)).timestamp()),
            "jti": secrets.token_urlsafe(16),
            "response_type": "code",
            "client_id": self.client_id,
            "redirect_uri": self.redirect_uri,
            "scope": " ".join(scope),
            "state": state,
            "nonce": nonce,
            "code_challenge": code_challenge,
            "code_challenge_method": "S256",
            "acr_values": acr_values,
            "ui_locales": ui_locales,
        }

        if claims:
            request_object_claims["claims"] = claims

        # Firma il Request Object con la chiave privata del RP
        request_object = jwt.encode(
            request_object_claims,
            self.private_key,
            algorithm="RS256",
            headers={"kid": "rp-signing-key-2024"}
        )

        # Costruisci URL autorizzazione
        import urllib.parse
        params = {
            "client_id": self.client_id,
            "response_type": "code",
            "scope": " ".join(scope),
            "redirect_uri": self.redirect_uri,
            "state": state,
            "code_challenge": code_challenge,
            "code_challenge_method": "S256",
            "request": request_object,  # Request Object firmato
        }

        auth_url = f"{idp_authorization_endpoint}?{urllib.parse.urlencode(params)}"

        session_data = {
            "state": state,
            "nonce": nonce,
            "code_verifier": code_verifier,
        }

        return auth_url, session_data

    async def exchange_code_for_tokens(
        self,
        authorization_code: str,
        code_verifier: str,
        idp_token_endpoint: str
    ) -> dict:
        """
        Scambia il codice di autorizzazione per i token (access_token, id_token).
        Usa Client Authentication con private_key_jwt (obbligatorio in SPID/CIE).
        """
        now = int(datetime.utcnow().timestamp())

        # client_assertion: JWT firmato per autenticare il RP al token endpoint
        client_assertion = jwt.encode(
            {
                "iss": self.client_id,
                "sub": self.client_id,
                "aud": idp_token_endpoint,
                "iat": now,
                "exp": now + 300,
                "jti": secrets.token_urlsafe(16),
            },
            self.private_key,
            algorithm="RS256",
            headers={"kid": "rp-signing-key-2024"}
        )

        async with httpx.AsyncClient() as client:
            response = await client.post(
                idp_token_endpoint,
                data={
                    "grant_type": "authorization_code",
                    "code": authorization_code,
                    "redirect_uri": self.redirect_uri,
                    "code_verifier": code_verifier,
                    "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
                    "client_assertion": client_assertion,
                    "client_id": self.client_id,
                }
            )
            response.raise_for_status()
            return response.json()

    def validate_id_token(
        self,
        id_token: str,
        idp_jwks_uri: str,
        nonce: str,
        expected_acr: str = None
    ) -> dict:
        """
        Valida l'ID Token ricevuto dall'IdP.
        Verifica firma, nonce, audience, e livello di autenticazione (acr).
        """
        # Recupera le chiavi pubbliche dell'IdP
        import httpx
        jwks = httpx.get(idp_jwks_uri).json()

        # Decodifica e valida il JWT
        claims = jwt.decode(
            id_token,
            jwks,
            algorithms=["RS256", "ES256"],
            audience=self.client_id,
            options={"require": ["nonce", "acr"]}
        )

        # Verifica nonce (anti-replay)
        if claims.get("nonce") != nonce:
            raise ValueError("Invalid nonce in ID Token")

        # Verifica livello di autenticazione se richiesto
        if expected_acr and claims.get("acr") != expected_acr:
            raise ValueError(f"ACR mismatch: expected {expected_acr}, got {claims.get('acr')}")

        return claims

SPID- en CIE-claims: gebruikerskenmerken

Een belangrijk verschil tussen SPID en typische particuliere OIDC-aanbieders betreft de beweringen (attributen gebruiker) beschikbaar. SPID/CIE biedt een reeks kenmerken die zijn gecertificeerd door de Italiaanse overheid:

OIDC claimen SPID-kenmerk Verkrijgbaar in CIE Opmerkingen
sub Unieke ID bij de IdP Si Het is niet de CF; veranderingen tussen verschillende IdP’s
fiscal_number Belasting-ID-code Si Formaat: TINIT-XXXXXXXXXXXXXX
given_name Naam Si OIDC-normen
family_name Achternaam Si OIDC-normen
birthdate Geboortedatum Si Formaat: JJJJ-MM-DD
place_of_birth Geboorteplaats Si Kadastrale code van de gemeente
gender Seks Si M/V
email E-mail (niet gecertificeerd) No Alleen SPID, aangegeven door de gebruiker
mobile_phone Mobiele telefoon No Alleen SPID, aangegeven door de gebruiker
document_details Documentgegevens Si Alleen CIE: num. CIE, vervaldatum, veelvoorkomend probleem

Waarschuwing: sub versus fiscaal_nummer

Il sub claim in SPID is de gebruikers-ID bij een specifieke IdP, geen globale identificatie. Als een gebruiker van IdP verandert (bijvoorbeeld van Aruba naar Poste), wordt de sub wijziging. De belastingwet (fiscal_number) is de enige stabiele en mondiale identificatie voor een burger Italiaans. Gebruik de fiscal_number als de primaire sleutel in uw database, niet de sub.

AGID onboarden: een dienstverlener worden

Om SPID of CIE OIDC in uw dienst te integreren, moet u het accreditatieproces voltooien als Vertrouwende Partij (RP) bij AgID. Het proces is onderverdeeld in:

  1. Registratie op ontwikkelaars.italia.it: maak een account aan en log in op het platform van SPID/CIE-onboarding.
  2. Technische voorbereiding: Implementeer uw SP met een van de officiële SDK's of een implementatie gewoonte. Configureer de metagegevens van de Relying Party (Entiteitsconfiguratie JWT).
  3. Staging-omgeving: AGID biedt een test-IdP (spid-test.agid.gov.it voor SAML, demo-oidc.agid.gov.it voor OIDC) met vooraf gedefinieerde testgebruikers.
  4. Technische validatie: uw SP wordt getest met de officiële AGID-validatietool. Ze moeten alle verplichte testgevallen doorstaan.
  5. Juridische overeenkomst: ondertekening van de lidmaatschapsovereenkomst met AgID (of met de gekozen aggregator, in het geval van lidmaatschap via aggregator).
  6. Productie: Na goedkeuring wordt uw SP geregistreerd in productie en gebruikers echte mensen kunnen zich authenticeren.
# Entity Configuration del Relying Party (JWT firmato)
# Questo documento deve essere pubblicato all'URL: {client_id}/.well-known/openid-federation

import jwt
import json
from datetime import datetime, timedelta

def generate_entity_configuration(
    client_id: str,  # URI del tuo servizio, es: https://servizi.miocomune.it
    private_key,
    public_key_jwk: dict,
    redirect_uris: list,
    organization_name: str,
    contacts: list
) -> str:
    """
    Genera l'Entity Configuration JWT per la registrazione OIDC Federation.
    Deve essere publicata a: {client_id}/.well-known/openid-federation
    """
    now = int(datetime.utcnow().timestamp())

    payload = {
        # Claims standard OIDC Federation
        "iss": client_id,
        "sub": client_id,
        "iat": now,
        "exp": now + 86400 * 365,  # Valida 1 anno (da aggiornare)
        "jwks": {"keys": [public_key_jwk]},  # Chiave pubblica del RP

        # Metadata del Relying Party
        "metadata": {
            "openid_relying_party": {
                "application_type": "web",
                "client_id": client_id,
                "client_registration_types": ["automatic"],
                "redirect_uris": redirect_uris,
                "response_types": ["code"],
                "grant_types": ["authorization_code"],
                "id_token_signed_response_alg": "RS256",
                "userinfo_signed_response_alg": "RS256",
                "token_endpoint_auth_method": "private_key_jwt",
                "token_endpoint_auth_signing_alg": "RS256",
                "scope": ["openid", "profile", "email", "offline_access"],
                "client_name": organization_name,
                "contacts": contacts,
                # Parametri obbligatori per SPID/CIE
                "policy_uri": f"{client_id}/privacy",
                "logo_uri": f"{client_id}/logo.png",
                "subject_type": "pairwise",
                "request_object_signing_alg": "RS256",
            }
        },

        # Trust chain verso la Trust Anchor di AgID
        "authority_hints": ["https://registry.agid.gov.it"],
    }

    return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": "rp-signing-key-2024"})

# Endpoint FastAPI per esporre l'Entity Configuration
from fastapi import FastAPI
from fastapi.responses import Response

app = FastAPI()

@app.get("/.well-known/openid-federation")
async def entity_configuration():
    ec_jwt = generate_entity_configuration(
        client_id="https://servizi.miocomune.it",
        private_key=private_key,  # Caricata da HSM o file sicuro
        public_key_jwk=public_key_jwk,
        redirect_uris=["https://servizi.miocomune.it/auth/callback"],
        organization_name="Comune di Esempio",
        contacts=["tech@miocomune.it"]
    )

    return Response(
        content=ec_jwt,
        media_type="application/entity-statement+jwt"
    )

pagoPA: Betalingsintegratie

betaalPA is het nationale platform voor betalingen aan PA’s, beheerd door PagoPA S.p.A. Het stelt burgers in staat belastingen, boetes, vergoedingen en andere PA-diensten te betalen via een uitgebreid netwerk van kanalen (banken, postkantoren, betaalapps, Satispay, etc.).

Vanuit technisch oogpunt biedt de pagoPA-integratie voor een Creditor Institution (EC) het volgende:

  • Lidmaatschap van het pagoPA-knooppunt: via het PagoPA-lidmaatschapsportaal. Organisaties kunnen zich aansluiten rechtstreeks of via geautoriseerde technologische tussenpersonen.
  • IUV-generatie (Unique Payment Identifier): code die op unieke wijze identificeert eventuele verwachte betaling. Het formaat wordt gedefinieerd door de Creditor Body, maar moet voldoen aan de pagoPA-regels.
  • Schuldpositie: Elke schuld die de burger heeft jegens de EC wordt op het knooppunt geregistreerd pagoPA als een ‘schuldpositie’ met een bijbehorende IUV.
  • Verificatie en afsluiting: pagoPA informeert de EC wanneer een betaling is voltooid, de EC verifieert en sluit de schuldpositie af.
# Integrazione pagoPA - Generazione posizione debitoria
# API SOAP/REST verso il Nodo pagoPA

import httpx
import uuid
from datetime import datetime, timedelta
from dataclasses import dataclass

@dataclass
class PaymentPosition:
    iuv: str               # Identificativo Univoco Versamento
    amount_cents: int      # Importo in centesimi di euro
    description: str       # Causale del pagamento
    citizen_fiscal_code: str
    due_date: datetime
    company_name: str      # Denominazione dell'Ente Creditore

class PagoPAClient:
    """
    Client per le API del Nodo pagoPA (versione REST/JSON).
    Supporta le API GPD (Gestione Posizioni Debitorie).
    """

    def __init__(self, organization_fiscal_code: str, api_key: str, base_url: str):
        self.org_fc = organization_fiscal_code
        self.api_key = api_key
        self.base_url = base_url

    def generate_iuv(self) -> str:
        """
        Genera un IUV conforme alle specifiche pagoPA.
        Struttura per Enti con aux digit 3 (applicativo gestionale):
        - 17 caratteri numerici
        - Deve essere univoco per l'Ente Creditore
        """
        # Componente temporale: YYMMDDHHMM (10 cifre)
        time_component = datetime.utcnow().strftime("%y%m%d%H%M")
        # Componente random: 7 cifre
        random_component = str(uuid.uuid4().int)[:7]
        iuv = f"{time_component}{random_component}"
        return iuv[:17]  # Tronca a 17 caratteri

    async def create_payment_position(self, position: PaymentPosition) -> dict:
        """
        Crea una posizione debitoria sul nodo pagoPA.
        """
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.base_url}/organizations/{self.org_fc}/debtpositions",
                headers={
                    "Ocp-Apim-Subscription-Key": self.api_key,
                    "Content-Type": "application/json"
                },
                json={
                    "iupd": f"{self.org_fc}-{position.iuv}",  # Identificativo Univoco Posizione Debitoria
                    "type": "F",                          # F = Persona fisica
                    "fiscalCode": position.citizen_fiscal_code,
                    "companyName": position.company_name,
                    "validityDate": position.due_date.isoformat(),
                    "paymentOption": [
                        {
                            "iuv": position.iuv,
                            "amount": position.amount_cents,
                            "description": position.description,
                            "isPartialPayment": False,
                            "dueDate": (position.due_date + timedelta(days=30)).isoformat(),
                            "fee": 100,  # Commissione in centesimi (1 euro)
                            "transfer": [
                                {
                                    "idTransfer": "1",
                                    "amount": position.amount_cents,
                                    "organizationFiscalCode": self.org_fc,
                                    "remittanceInformation": position.description,
                                    "category": "0201102IM",  # Codice tassonomia
                                }
                            ]
                        }
                    ]
                }
            )
            response.raise_for_status()
            return response.json()

    def generate_payment_notice_url(self, iuv: str) -> str:
        """
        Genera il link di pagamento che il cittadino può usare.
        Formato standard: https://checkout.pagopa.it/pay?...
        """
        notice_number = f"3{self.org_fc}{iuv}"  # Numero Avviso pagoPA
        return (
            f"https://checkout.pagopa.it/pay"
            f"?rptId={self.org_fc}{notice_number}"
            f"&amount={100}"  # Amount in centesimi
        )

Test- en ontwikkelomgevingen

AgID en PagoPA bieden speciale testomgevingen voor ontwikkeling en validatie:

Systeem Omgeving URL Referenties testen
SPID OIDC Demo/test demo.spid.gov.it Test gebruikers op developers.italia.it
CIE OIDC Testen preprod.cie.gov.it Aanvraag via MinInterno portal
SPID-SAML IdP-testen spidtest.agid.gov.it toets/toets (niveaus 1, 2, 3)
payPA GPD UAT api.uat.platform.pagopa.it API-sleutel aangevraagd op devOps-portal
pagoPA Afrekenen UAT uat.checkout.pagopa.it Testkaart: 4242 4242 4242 4242

Officiële SDK's en aanbevolen bibliotheken

Het project Ontwikkelaars Italië (developers.italia.it) onderhoudt officiële SDK's voor integratie van SPID en CIE voor 5 programmeertalen:

  • Python: spid-cie-oidc-django (gebaseerd op Django), pyspid (SAML)
  • Java: spid-spring-integration (Lentelaars)
  • .NETTO: spid-dotnet-sdk (ASP.NET kern)
  • PHP: spid-php
  • Robijn: spid-ruby

Voor integraties in contexten die niet onder de officiële SDK's vallen, zijn volledige technische specificaties beschikbaar op docs.italia.it (zoek naar "SPID CIE OIDC" of "SPID Technical Rules"). De OIDC-federatie kan dat wel zijn geïmplementeerd met elke standaard OIDC-bibliotheek, waardoor ondersteuning wordt toegevoegd voor SPID/CIE-specifieke claims.

Conclusies en volgende stappen

De integratie van SPID, CIE en pagoPA in Italiaanse digitale diensten is een vereiste voor elke PA die dat doet CAD-compatibele onlinediensten willen aanbieden. De overstap naar OpenID Connect vereenvoudigt aanzienlijk de implementatie vergeleken met de historische SAML 2.0, en de officiële SDK's van Developers Italia zijn verder lager de toegangsbarrière.

In het volgende en laatste artikel van deze serie verkennen we de GovStack-bouwsteen: het raamwerk internationaal modulair systeem om herbruikbare digitale overheidsdiensten te bouwen, dat in 2025 door meer dan 20 landen is ingevoerd.

Gerelateerde artikelen in deze serie

  • GovTech #00: Digitale publieke infrastructuur - bouwstenen en mondiale architectuur
  • GovTech #01: eIDAS 2.0 en EUDI Wallet - Europese digitale identiteit
  • GovTech #02: OpenID Connect voor overheidsidentiteit - volledige uitrol
  • GovTech #04: GDPR-by-Design - gebruikersgegevens en privacy in PA-services