Deploy your own connector
What you will get: a consumer connector for your organisation in your own Kubernetes cluster. It gives your organisation a DID and issues its credentials: the LegalPersonCredential of its legal representative, to buy in the marketplace, and the MachineCredential of its programs, to read data. That is all a consumer needs; the provider components can be added later.
Do you need it?
To join, buy and read data you do not: the onboarding portal gives your organisation a credential issuer of its own (option A of Join the data space). Deploy a connector to run your own infrastructure, or to become a provider.
First version of this guide
The configuration comes from the connectors running in the CitCom.ai Data Space, but this guide has not yet been followed from start to finish on a new cluster. Report anything that does not work in the repository.
flowchart LR
subgraph NS["Your namespace"]
KC["Keycloak<br/>issues the credentials"]
DH["did-helper<br/>publishes did.json"]
PG[("PostgreSQL")]
KEY["DID key<br/>Secret"]
KC --- PG
KEY --> KC
KEY --> DH
end
W["Wallet of the LEAR"] -- "LegalPersonCredential" --> KC
P["Your programs"] -- "MachineCredential" --> KC
V["Verifiers of the data space"] -- "resolve your DID" --> DH
The same key signs your credentials (in Keycloak) and is published in your DID document (by did-helper). That is how the verifiers of other participants check that a credential is yours.
What you need
- A Kubernetes cluster reachable from the internet, with an ingress controller (ingress-nginx).
- cert-manager with two
ClusterIssuers: one for Let's Encrypt (hereletsencrypt) and one self-signed (hereselfsigned-issuer). - Two public host names in a domain you control, pointing to your ingress. This guide uses:
did.example.org: your DID will bedid:web:did.example.org;keycloak.example.org: your credential issuer.
kubectland Helm 3.
Replace example.org with your domain everywhere.
Steps
1. Create the namespace and the secrets
kubectl create namespace connector
# Admin password of Keycloak, and the password of the keystore that holds the DID key
kubectl -n connector create secret generic issuance-secret \
--from-literal=keycloak-admin="$(openssl rand -base64 24)" \
--from-literal=store-pass="$(openssl rand -hex 16)"
# Database user of Keycloak
kubectl -n connector create secret generic database-credentials \
--from-literal=username=keycloak \
--from-literal=password="$(openssl rand -hex 16)"
2. Create the DID key
An EC P-256 key, issued by the self-signed issuer. Its certificate is only a container for the key: it is not used for TLS.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: did-key
namespace: connector
spec:
commonName: did.example.org
dnsNames: [did.example.org]
secretName: did-key
privateKey:
algorithm: ECDSA
size: 256
usages: [digital signature, key encipherment]
issuerRef:
group: cert-manager.io
kind: ClusterIssuer
name: selfsigned-issuer
kubectl apply -f did-key.yaml
kubectl -n connector wait --for=condition=Ready certificate/did-key --timeout=120s
3. Publish your DID with did-helper
image:
repository: quay.io/seamware/did-helper
tag: 0.4.4
config:
server:
hostUrl: https://did.example.org
certPath: /certs/tls.crt
generateKey:
enabled: false
volumes:
- name: certs
secret:
secretName: did-key
items: [{ key: tls.crt, path: tls.crt }]
volumeMounts:
- { name: certs, mountPath: /certs, readOnly: true }
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt
hosts:
- host: did.example.org
paths: [{ path: /, pathType: Prefix }]
tls:
- secretName: did-example-org-tls
hosts: [did.example.org]
helm repo add fiware https://fiware.github.io/helm-charts/
helm install did fiware/did-helper -n connector -f did-helper.yaml
Check it, from a machine outside your network:
It returns a DID document whose id is did:web:did.example.org.
4. Install PostgreSQL and Keycloak
PostgreSQL, with a database for Keycloak:
fullnameOverride: postgres
auth:
username: keycloak
database: keycloak
existingSecret: database-credentials
secretKeys:
adminPasswordKey: password
helm install postgres oci://registry-1.docker.io/cloudpirates/postgres --version 0.20.3 \
-n connector -f postgres.yaml
The realm. Download the consumer realm and replace the two host names:
curl -sLO https://citcomai-hub.github.io/assets/connector/consumer-realm.json
sed -i 's/DID_HOST/did.example.org/g; s/KEYCLOAK_HOST/keycloak.example.org/g' consumer-realm.json
kubectl -n connector create configmap realm --from-file=provider-realm.json=consumer-realm.json
The realm, called provider, defines everything the data space expects from an issuer:
| Item | Value | Used by |
|---|---|---|
Credential LegalPersonCredential |
SD-JWT (dc+sd-jwt), ES256, bound to the holder's key; claims firstName, lastName, email, and roles from the client marketplace |
The marketplace login |
Credential MachineCredential |
Same format; claims email and roles from the client roles-dataspace |
The verifiers of the providers |
Credential ServiceCredential |
Same format; claims from roles-dataspace |
Web services bought by your organisation |
Client marketplace |
Role REPRESENTATIVE |
The LEAR |
Client roles-dataspace |
Roles clearence1 to clearence4; add the roles your offers ask for |
Your programs and people |
Client account-console |
Direct access grants and OID4VCI enabled | ds-consumer-auth |
Client atesta-id-wallet |
Public, PKCE, redirect to http://127.0.0.1/callback |
atesta-id-wallet: login in the browser |
| Signing key | The DID key, from the keystore /did-material/cert.pfx, with kid = your DID |
Every credential |
The ${…} values left in the file (${STORE_PASS}, ${role_…}) are read by Keycloak itself: leave them as they are.
Keycloak. It must be the SEAMWARE image 26.6.2: it includes the credential offer endpoint that the wallets and ds-consumer-auth use. An init container packs the DID key into the keystore that the realm reads.
image:
registry: quay.io
repository: seamware/keycloak
tag: "26.6.2"
keycloak:
adminUser: keycloak-admin
existingSecret: issuance-secret
secretKeys:
adminPasswordKey: keycloak-admin
hostname: https://keycloak.example.org
httpEnabled: true
httpsEnabled: false # TLS ends at the ingress
proxyHeaders: xforwarded
production: true
# Keep the providers and themes bundled in the SEAMWARE image
preserveProviders: true
preserveThemes: true
extraEnvVars:
- name: STORE_PASS
valueFrom: { secretKeyRef: { name: issuance-secret, key: store-pass } }
extraVolumes:
- { name: did-material, emptyDir: {} }
- { name: realms, configMap: { name: realm } }
- { name: did-key, secret: { secretName: did-key } }
extraVolumeMounts:
- { name: did-material, mountPath: /did-material }
- { name: realms, mountPath: /opt/keycloak/data/import }
extraInitContainers:
- name: create-did-keystore
image: ubuntu:22.04
command: [/bin/bash, -ec]
args:
- |
apt-get -y update && apt-get -y install --no-install-recommends openssl ca-certificates
openssl pkcs12 -export -out /did-material/cert.pfx \
-inkey /did-key/tls.key -in /did-key/tls.crt \
-certfile /etc/ssl/certs/ISRG_Root_X1.pem \
-name didPrivateKey -passout env:STORE_PASS
chmod 666 /did-material/cert.pfx
env:
- name: STORE_PASS
valueFrom: { secretKeyRef: { name: issuance-secret, key: store-pass } }
volumeMounts:
- { name: did-material, mountPath: /did-material }
- { name: did-key, mountPath: /did-key }
realm:
import: true
features:
enabled: [oid4vc-vci, oid4vc-vci-preauth-code, oid4vc-vci-rest-credential-offer]
postgres:
enabled: false
database:
type: postgres
host: postgres
port: "5432"
name: keycloak
existingSecret: database-credentials
secretKeys: { usernameKey: username, passwordKey: password }
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt
hosts:
- host: keycloak.example.org
paths: [{ path: /, pathType: Prefix }]
tls:
- secretName: keycloak-example-org-tls
hosts: [keycloak.example.org]
resources:
requests: { cpu: 500m, memory: 640Mi }
limits: { cpu: "1", memory: 2Gi }
helm install keycloak oci://registry-1.docker.io/cloudpirates/keycloak --version 0.21.4 \
-n connector -f keycloak.yaml
kubectl -n connector rollout status statefulset/keycloak --timeout=10m
Check that it issues credentials:
curl -s https://keycloak.example.org/realms/provider/.well-known/openid-credential-issuer \
| python3 -c 'import json,sys; print(sorted(json.load(sys.stdin)["credential_configurations_supported"]))'
It prints ['LegalPersonCredential', 'MachineCredential', 'ServiceCredential'].
The realm is imported only once
Keycloak imports the realm file only if the realm does not exist yet. Later changes to the file do not reach a running Keycloak: make them in the admin console, or with its admin API.
5. Create the users
Open the admin console at https://keycloak.example.org/admin, user keycloak-admin, with the password of step 1:
kubectl -n connector get secret issuance-secret -o jsonpath='{.data.keycloak-admin}' | base64 -d; echo
In the realm provider, create one user per role, never one user for everything:
| User | Roles (client → role) | Credential to bind |
|---|---|---|
| The LEAR, with email, first and last name | marketplace → REPRESENTATIVE |
LegalPersonCredential |
A machine user for each program, such as reader1 |
roles-dataspace → the role of the offers it reads (clearence1) |
MachineCredential |
For each one: Users → Add user, then Credentials → Set password (not temporary), then Role mapping → Assign role.
Having the role is not enough: Keycloak only issues the credentials that are bound to the user. Bind them with the admin API:
KC=https://keycloak.example.org
ADMIN_PASSWORD=$(kubectl -n connector get secret issuance-secret -o jsonpath='{.data.keycloak-admin}' | base64 -d)
TOKEN=$(curl -s "$KC/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli -d username=keycloak-admin \
--data-urlencode "password=$ADMIN_PASSWORD" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
bind() { # bind <username> <credential>
USER_ID=$(curl -s -H "Authorization: Bearer $TOKEN" "$KC/admin/realms/provider/users?username=$1&exact=true" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])')
curl -s -o /dev/null -w "$1 $2: %{http_code}\n" -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d "{\"credentialScopeName\": \"$2\"}" \
"$KC/admin/realms/provider/users/$USER_ID/vc/credentials"
}
bind lear LegalPersonCredential
bind reader1 MachineCredential
Each call prints a 2xx code. Log in to the account console ($KC/realms/provider/account) as each user: Verifiable Credentials lists the bound credential.
6. Register in the data space
Join the data space with option B and your DID, did:web:did.example.org. When the registration is approved, your credentials are trusted.
Check that it works
With the machine user, the ds-consumer-auth library can get a credential from your Keycloak:
pip install "ds-consumer-auth @ git+https://github.com/CitComAI-Hub/ds-consumer-auth@v0.1.0"
python3 - <<'EOF'
from ds_consumer_auth import ConsumerConfig, CredentialIssuer, HolderKey
issuer = CredentialIssuer(ConsumerConfig(
issuer_url="https://keycloak.example.org/realms/provider",
username="reader1", password="<its password>"))
credential = issuer.issue(HolderKey.generate())
print(credential.claims["iss"], credential.claims["vct"])
EOF
It prints did:web:did.example.org MachineCredential. To read data, buy an offer and follow Use data from your code.
Before going to production
- Back up the Secret
did-key: if the key is lost, your organisation needs a new DID and a new registration. - Keep the Keycloak admin password and the user passwords in a secret store, not in a repository.
- Give each user and program only the roles it needs. A verifier rejects a credential that carries a role the purchase does not allow.
If something fails
| Symptom | Cause | Fix |
|---|---|---|
did.json returns 404 or a TLS error |
The ingress or the Let's Encrypt certificate of did.example.org is not ready |
kubectl -n connector get ingress,certificate |
| The credential endpoints fail, or Keycloak logs errors about missing OID4VC providers | The chart hid the providers of the image | Keep preserveProviders: true |
| Keycloak does not start: keystore error | STORE_PASS differs between the init container and Keycloak, or the key Secret is missing |
Both read issuance-secret; check that did-key exists |
| The issuer metadata does not list the credentials | Wrong image, or the OID4VCI features are not enabled | Use quay.io/seamware/keycloak:26.6.2 and the three features of step 4 |
The metadata shows http:// addresses |
Keycloak does not see the original scheme | Keep hostname: https://… and proxyHeaders: xforwarded |
| You have no verifiable credentials yet in the account console | The credential is not bound to the user | The bind call of step 5 |
| The verifiers of other participants reject your credentials | Your registration is not approved, or the kid of the key does not match your DID |
Check the status in the onboarding portal; check that DID_HOST was replaced in the realm |
Become a provider
To publish data or services, the connector also needs the provider components of the FIWARE Data Space Connector (CitCom runs version 10):
| Component | Role |
|---|---|
| Verifier | Turns the credentials of other organisations into access tokens |
| APISIX + OPA | Gateway in front of your data and services, with the policy engine |
| ODRL-PAP and Trusted Issuers List | Your access policies and the organisations you trust |
| contract-management | Applies the purchases of your offers coming from the marketplace |
| An NGSI-LD broker | Your data, or Lepus in front of an NGSI-v2 platform |
See Inside a connector for how they work together, then Publish a dataset. Lessons from running them in CitCom:
- Extra routes: the chart's default route
/ngsi-ld/*goes to its built-in broker. Routes for other brokers (such as/lepus/ngsi-ld/*) or web services are added to APISIX with the same token check and OPA policy. - contract-management must be reachable by the marketplace; set its address in your organisation's marketplace profile.
- Images: deploy your own images with a new version tag each time; a node does not pull again a tag it already has.
Learn locally first
- The CitCom Minimum Viable Data Space deploys a minimal FIWARE data space (trust anchor, provider and consumer) in a local Kind cluster.
- FIWARE's local deployment is more complete and needs more resources.