A self-service portal that allows organizations to register on a decentralized trust infrastructure. Upon submission, the platform provisions a dedicated Keycloak realm, generates a DID (did:web), and registers the organization in the Trust Issuer Registry (TIR).
- Prerequisites
- Onboarding Flow
- Configuration
- Enabling Dynamic DID Generation
- Running Locally (Development)
- Running with Docker
- Deploying with Helm (Kubernetes)
- API Reference
| Dependency | Version | Purpose |
|---|---|---|
| Node.js | 22+ | Backend runtime |
| pnpm | 10+ | Package manager |
| PostgreSQL | 15+ | Data persistence |
| Keycloak | 26+ with OID4VC | Authentication & realm provisioning |
| did-helper | — | DID document hosting with Keycloak integration (see below) |
| SMTP server | any | Email notifications |
| TIR | — | Trust Issuer Registry (optional) |
did-helper must be configured with Keycloak integration enabled so that newly created realms can resolve their
did:webdocuments. The portal calls did-helper to register the DID after provisioning each realm — without it, verifiable credential issuance will not work. PointdidGenerator.didWebHostinapplication.yamlto the domain served by your did-helper instance.The following did-helper configuration is required to enable Keycloak-backed DID resolution:
config: server: runServer: "true" didType: keycloak keycloakHost: https://<your-keycloak-host> outputFormat: "none"
The following describes the end-to-end lifecycle of an organization joining the trust infrastructure.
DID-provided registrations: This full workflow only applies when the applicant does not supply a DID at registration time. If a DID is provided, the portal skips Keycloak realm provisioning entirely (Steps 2–4 below are not performed). TIR registration still occurs in both cases.
Dynamic DID creation: By default the registration form requires the applicant to supply an existing DID. To let the portal generate one automatically, enable the
didCreationEnabledflag — see Enabling Dynamic DID Generation.
A representative of the new organization fills in the registration form in the portal and submits it. The portal saves the request and sends a confirmation email to the applicant.
A portal administrator reviews the pending request in the admin panel. Once satisfied, the admin approves the application. The portal then automatically:
- Provisions a dedicated Keycloak realm for the organization.
- Generates a
did:webidentifier and registers it with the did-helper. - Registers the organization in the Trust Issuer Registry (TIR).
Upon approval the organization contact receives two emails:
- Keycloak email — sent by the newly provisioned realm asking the user to verify their information and set a password (triggered by the
VERIFY_EMAILandUPDATE_PASSWORDrequired actions configured inadminUserConfig). - Portal activation email — sent by the portal with two action buttons:
- Admin panel — opens the Keycloak admin console for the organization's realm.
- Credentials — opens the credential issuance interface.
Email delivery: For Keycloak to send the verification email, the SMTP server must be configured in
app.keycloak.defaultRealmConfig.smtpServer. Without it, the Keycloak email in step 3 will not be delivered. Example:app: keycloak: defaultRealmConfig: smtpServer: auth: true from: onboarding@seamware.io fromDisplayName: Onboarding Auth host: smtp.ethereal.email password: ${EMAIL_PASSWORD} port: 587 ssl: false starttls: true user: ${EMAIL_USER}
Important: The default admin user created automatically in each realm (configured via
app.keycloak.adminUserConfig) has realm-management privileges but cannot issue Verifiable Credentials. VC issuance requires a regular user with theconsumerrole assigned (see Step 4).
The organization admin must log into the Keycloak admin console and create end users within their realm. Each user that needs to issue VCs must be assigned the consumer role, which is defined by default in the provisioned realm.
Admin console → Users → Add user → Assign role: consumer
# ──────────────────────────────────────────────
# HTTP Server
# ──────────────────────────────────────────────
server:
port: 8080 # Listening port
staticPath: ./static # Path to the compiled Angular build
trustProxy: 1 # Number of trusted proxy hops (set to 1 behind a load balancer)
jsonBodyLimit: 100kb # Maximum JSON request body size
storage:
destFolder: files # Root folder for uploaded files (relative to cwd)
maxSizeMB: 5 # Maximum size per uploaded file in MB
cors:
origin: "*" # Allowed origins. Use a specific URL in production
methods: [GET, POST, PUT, DELETE, OPTIONS]
allowedHeaders: [Content-Type, Authorization, X-Organization]
credentials: true
maxAge: 600 # Preflight cache TTL (seconds)
# ──────────────────────────────────────────────
# Logging
# ──────────────────────────────────────────────
logging:
level: info # error | warn | info | http | verbose | debug
# ──────────────────────────────────────────────
# Database (PostgreSQL)
# ──────────────────────────────────────────────
database:
type: postgres
host: localhost
port: 5432
username: postgres
password: postgres
database: onboarding
synchronize: true # Auto-sync schema on startup. Set to false in production
logging: false # Log SQL queries
timezone: "Z" # Force UTC for date storage (required for MySQL; ignored by PostgreSQL)
# ──────────────────────────────────────────────
# Application
# ──────────────────────────────────────────────
app:
documentToSignUrl: https://... # URL of the document users must accept at registration
# OIDC login (used for admin access)
login:
openIdUrl: https://<keycloak>/realms/<realm> # OpenID Connect discovery URL
clientId: onboarding # OIDC client ID
clientSecret: <secret> # OIDC client secret
scope: openid # Requested OIDC scope
codeChallenge: true # Enable PKCE (recommended)
# Keycloak admin connection (used to provision realms)
keycloak:
baseUrl: https://<keycloak>
realmName: master # Realm where the admin client lives
auth:
username: <admin-user>
password: <admin-password>
grantType: password
clientId: admin-cli
realmName: master # Realm where the admin user exists and has admin privileges.
# The user must have permission to create and delete realms
# (typically the built-in "admin" user in the master realm).
# Enable dynamic DID creation during realm provisioning.
# When false (default), applicants must supply their own DID at registration time.
didCreationEnabled: false
# Generated realm settings
realmNameLength: 36 # Length of randomly generated realm names
adminPasswordLength: 30 # Length of generated admin passwords
adminEmailLifespan: 72h # Expiry of the admin welcome email action link
# Admin user created inside each provisioned realm
adminUserConfig:
enabled: true # Create the admin user (disable to skip user creation)
username: admin # Username for the realm admin
emailVerified: false # Whether the email is pre-verified
groups:
- /admin
clientRoles: # Client roles assigned to the admin user
realm-management:
- manage-users
- query-groups
- query-users
- view-users
account:
- manage-account
- view-groups
- view-profile
realmRoles: []
requiredActions: # Actions forced on first login
- VERIFY_EMAIL
- UPDATE_PASSWORD
# Elliptic curve for signing keys
keys:
curveType: P-256 # P-256 | P-384 | P-521
# Additional client scopes added to every provisioned realm (OID4VC credential scopes)
additionalClientScopes:
- name: LegalPersonCredential
description: OIDC4VC Scope, that adds all properties required for a user.
protocol: openid-connect
attributes:
include.in.token.scope: "false"
display.on.consent.screen: "false"
protocolMappers: # OID4VC mappers for SD-JWT credential issuance
- name: context-mapper
protocol: oid4vc
protocolMapper: oid4vc-context-mapper
config:
context: https://www.w3.org/2018/credentials/v1
supportedCredentialTypes: LegalPersonCredential
- name: firstName-mapper
protocol: oid4vc
protocolMapper: oid4vc-user-attribute-mapper
config:
subjectProperty: firstName
supportedCredentialTypes: LegalPersonCredential
userAttribute: firstName
- name: email-mapper
protocol: oid4vc
protocolMapper: oid4vc-user-attribute-mapper
config:
subjectProperty: email
supportedCredentialTypes: LegalPersonCredential
userAttribute: email
- name: lastName-mapper
protocol: oid4vc
protocolMapper: oid4vc-user-attribute-mapper
config:
subjectProperty: lastName
supportedCredentialTypes: LegalPersonCredential
userAttribute: lastName
- name: role-mapper
protocol: oid4vc
protocolMapper: oid4vc-target-role-mapper
config:
subjectProperty: roles
supportedCredentialTypes: LegalPersonCredential
clientId: ${DID}
# Template applied to every newly created Keycloak realm
defaultRealmConfig:
enabled: true # Activate the realm immediately after creation
verifiableCredentialsEnabled: true # Enable OID4VC on the realm
attributes:
preAuthorizedCodeLifespanS: 120 # Pre-authorized code lifetime (seconds)
issuerDid: ${DID} # Resolved at runtime — see placeholder table below
# SD-JWT credential profile
vc.user-sd.expiry_in_s: "31536000"
vc.user-sd.format: vc+sd-jwt
vc.user-sd.scope: LegalPersonCredential
vc.user-sd.vct: LegalPersonCredential
vc.user-sd.credential_signing_alg_values_supported: ES256
vc.user-sd.credential_build_config.token_jws_type: vc+sd-jwt
vc.user-sd.credential_build_config.visible_claims: roles,email
vc.user-sd.credential_build_config.proof_types_supported: '{"jwt":{"proof_signing_alg_values_supported":["ES256"]}}'
vc.user-sd.credential_build_config.decoys: "3"
vc.user-sd.credential_build_config.signing_algorithm: ES256
clients:
- clientId: ${DID} # One OIDC client per realm, keyed by its DID
enabled: true
protocol: openid-connect
publicClient: false
serviceAccountsEnabled: true
directAccessGrantsEnabled: true
components:
org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder:
- name: sd-jwt-builder # SD-JWT credential builder
providerId: vc+sd-jwt
- name: jwt-vc-builder # JWT-VC credential builder
providerId: jwt_vc
defaultDefaultClientScopes: [acr, roles, role_list, email, web-origins, profile]
defaultOptionalClientScopes: [LegalPersonCredential]
groups:
- name: admin # Admin group with realm-management roles
clientRoles:
realm-management:
- manage-users
- manage-realm
- query-users
- query-groups
- view-users
smtpServer:
host: smtp.example.com
port: "587"
auth: "true"
user: <smtp-user>
password: <smtp-password>
starttls: "true"
ssl: "false"
from: keycloak@example.com
fromDisplayName: Keycloak Auth
# Trust Issuer Registry
tir:
url: http://<tir-host>
# ──────────────────────────────────────────────
# Email (Nodemailer)
# ──────────────────────────────────────────────
email:
enabled: true # Set to false to disable all emails
type: nodemailer
from: onboarding@example.com
config:
service: Gmail # Nodemailer service shorthand, or omit and use host/port
auth:
user: <smtp-user>
pass: <smtp-password>
# Custom email templates (optional — defaults are embedded)
submit:
subject: "OnBoarding Portal - Registration submitted"
html: "file://./templates/submit.html"
update:
subject: "OnBoarding Portal - Registration updated"
html: "file://./templates/update.html"
active:
subject: "OnBoarding Portal - Registration activated"
html: "file://./templates/active.html"
# ──────────────────────────────────────────────
# DID generation
# ──────────────────────────────────────────────
didGenerator:
didWebHost: did:web:example.com # Base domain for generated did:web identifiersAny value in the YAML can reference an environment variable using ${VAR_NAME}:
database:
password: ${DB_PASSWORD}If the variable is not set the literal string ${DB_PASSWORD} is used — make sure all substitutions are resolved before starting the app.
Several fields inside app.keycloak.defaultRealmConfig and app.keycloak.additionalClientScopes contain ${DID}, ${REALM}, and ${ID} placeholders. These are not environment variables and must not be replaced by the operator — they are resolved automatically at runtime each time a new Keycloak realm is provisioned:
| Placeholder | Resolved value |
|---|---|
${DID} |
Full did:web identifier of the newly created realm (e.g. did:web:example.com:my-realm). Derived from didGenerator.didWebHost and the generated realm name. |
${REALM} |
Randomly generated realm name (alphanumeric string, length controlled by keycloak.realmNameLength). Used as the Keycloak realm identifier. |
${ID} |
Same value as ${REALM}. Used wherever Keycloak requires the internal realm ID. |
These placeholders allow the realm template to reference its own DID and name without hardcoding them, so every provisioned realm gets its own correctly scoped client and credential configuration.
By default (didCreationEnabled: false) the registration form requires applicants to provide an existing DID. When dynamic generation is enabled, the portal creates a did:web identifier automatically during realm provisioning, so applicants do not need to supply one.
- A running did-helper instance with Keycloak integration enabled. Without it the generated DID cannot be resolved and VC issuance will fail.
-
Set the flag in
application.yaml:app: keycloak: didCreationEnabled: true
-
Point
didGenerator.didWebHostat the domain served by your did-helper instance:didGenerator: didWebHost: did:web:example.com # base domain for generated did:web identifiers
At provisioning time the portal derives the full DID by appending the generated realm name:
did:web:example.com:<realm-name>.
- The registration form shows a DID input field and will not accept submissions without one.
- The portal skips DID generation entirely on approval; the applicant-supplied DID is used for Keycloak realm configuration and TIR registration.
# Terminal 1 — backend (TypeScript watch mode)
cd backend && pnpm install && pnpm run dev
# Terminal 2 — frontend (Angular dev server with hot reload)
cd frontend && pnpm install && pnpm startThe frontend dev server proxies /api calls to http://localhost:8080 automatically.
# Build frontend first
cd frontend && pnpm install && pnpm build
cd ..
# Build the Docker image (multi-stage: compiles backend + bundles frontend)
docker build -t onboarding-portal:latest .docker run -p 8080:8080 \
-v $(pwd)/backend/src/config/application.yaml:/app/application.yaml \
-v $(pwd)/files:/app/files \
onboarding-portal:latestThe application is available at http://localhost:8080.
Mount a host directory to
/app/filesto persist uploaded files across container restarts.
The chart/ directory contains a production-ready Helm chart.
helm upgrade --install onboarding ./chart \
--set ingress.enabled=true \
--set ingress.hosts[0].host=onboarding.example.com \
-f my-values.yamlreplicaCount: 1
image:
repository: mortega5/onboarding
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: nginx
hosts:
- host: onboarding.example.com
paths:
- path: /
pathType: Prefix
# Mount an application.yaml via ConfigMap
config:
app:
login:
openIdUrl: https://...
database:
host: postgres
...
# Inject secrets as environment variables (referenced in config via ${VAR})
secrets:
- name: onboarding-secrets # existing Kubernetes Secret
keys:
- DB_PASSWORD
- APP_CLIENT_SECRET
- APP_KEYCLOAK_PASSWORD
persistence:
enabled: true # Mount a PVC for uploaded files
size: 5Gi
storageClass: ""