Domains and Public Endpoints
A tenant's network identity is made up of two related but distinct things. The tenant_domain rows describe which hostnames are allowed to resolve to the tenant. The tenant_public_endpoint rows describe, per service (OID4VCI, OID4VP, OAuth AS), what host and path layout the tenant's metadata advertises. Both are CRUD entities under the tenant admin REST; the data planes read both at runtime.
tenant_domain: Who Resolves Here
A TenantDomain row binds a tenant to a fully-qualified host that resolves to it. Two kinds:
PLATFORM_SUBDOMAIN. A host of the form<slug>.<platform-base>, owned by the SaaS. Auto-created at tenant registration wheninitialPlatformSubdomain = true(the default). Always verified at insert time because the platform controls the DNS for<platform-base>. The tenant administrator does not have to do anything for the platform subdomain to work.CUSTOM_DOMAIN. A host the customer brings, such aswallet.acme.com. Inserted unverified; the customer completes the DNS challenge to verify ownership. The resolver chain skips unverified custom-domain rows, so the row can sit in the registry through the verification window without prematurely routing traffic.
A tenant can have many domains; a domain belongs to exactly one tenant. Global uniqueness on the domain column (where deleted_at is null) is enforced by the database. The lowercased host is stored without scheme or port.
Verification Flow
Custom domains follow a verification challenge:
- The tenant administrator adds the domain via
POST /api/v1/tenants/{tenantId}/domainswith kindCUSTOM_DOMAIN. The EDK mints averificationTokenand stores the row unverified. - The administrator publishes the verification token as a DNS TXT record (the exact record format is defined by the deployment).
- The verification worker (or the operator-triggered
POST /api/v1/tenants/{tenantId}/domains/{domainId}/verify) checks DNS, and on success callsMarkDomainVerifiedHttpEndpointCommand. TheverifiedAttimestamp is set, and the resolver chain starts returning the tenant for that host.
The DNS challenge flow itself ships separately from the basic CRUD; the EDK includes the verificationToken field and the markVerified command so a deployment can plug its verification worker in without re-inventing the model.
Primary Domain
One domain per tenant can carry isPrimary = true. This is the canonical issuer host the AS metadata renders when no service-specific public-endpoint binding overrides it. Typical patterns:
- Single platform subdomain:
acme.saas.comis automatically primary. - Customer migrates to a custom domain: the operator marks
wallet.acme.comprimary, and the platform subdomain stays as a fallback. - Multiple custom domains: only one is primary; the others are valid resolution targets but do not appear in advertised URLs unless they also carry a
tenant_public_endpointbinding.
tenant_public_endpoint: What URLs the Tenant Advertises
The tenant_public_endpoint table is the source of truth for what URLs the data planes put in metadata, in credential_offer_uri, in OID4VP request_uri_base, in OAuth issuer, in jwks_uri, and so on. The runtime URL resolver overlays in each data plane (lib-openid-oid4vci-issuer-rest-tenant, lib-openid-oid4vp-verifier-rest-tenant, lib-oauth2-server-rest-tenant) consult this table rather than synthesising URLs from the request host.
A TenantPublicEndpoint row binds:
tenantId. The owning tenant.serviceType. One ofOID4VCI_ISSUER,OID4VP_VERIFIER, orOAUTH2_AUTHORIZATION_SERVER. The set is closed because each service type has its own URL grammar.host. The lowercased host without scheme or port. When non-null, must be a verifiedtenant_domainrow for the same tenant. When null, the endpoint uses the runtime host or the default base.pathPrefix. The protocol route prefix, for example/acme/oid4vcifor an issuer that wants its protocol endpoints to live under a per-tenant slug prefix.wellKnownPath. The well-known route, for example/.well-known/openid-credential-issuer/acmefor the spec-canonical form.enabled. Lets an operator disable a binding temporarily without deleting it.primaryEndpoint. For service types that benefit from a primary marker (typically OAuth AS).
Why a Separate Table
The reason this is not just derived from tenant_domain is that one host can carry multiple services and one service can be advertised on a non-domain-shaped URL. A small example:
A tenant acme is registered with platform subdomain acme.saas.com. The deployment topology splits issuer, verifier, and AS onto distinct ingress hosts: issuer.saas.com, verifier.saas.com, and as.saas.com, all also reachable per-tenant as acme.issuer.saas.com, acme.verifier.saas.com, and acme.as.saas.com. The tenant's OID4VCI issuer metadata should advertise acme.issuer.saas.com. The verifier metadata should advertise acme.verifier.saas.com. The AS metadata should advertise acme.as.saas.com. The tenant_public_endpoint rows make those three bindings explicit, per service type, independently of which host the request happened to land on.
For a simpler topology where everything lives at acme.saas.com, three tenant_public_endpoint rows all point at the same host. The model handles both cases uniformly.
Fail-Closed Default
When no tenant_public_endpoint binding exists for the resolved tenant and service type, the runtime URL resolver refuses to advertise anything rather than falling back to the request host. This is the deliberate enterprise-deployment default: in a production environment the request host is often the cluster's internal host or the CDN's origin host, neither of which is suitable for wallet-facing metadata.
The fallback is configurable. A development deployment may flip the resolver to "use request host when no binding exists" by setting tenant.public_endpoint.fallback_to_request_host = true. The published Helm chart leaves the fail-closed default in place.
What the Data Planes Actually Do
Each data plane has its own URL resolver overlay, contributed by the tenant-aware rest module:
- Issuer (
lib-openid-oid4vci-issuer-rest-tenant). Replaces the IDKOid4vciIssuerPublicUrlResolverwithTenantPublicEndpointOid4vciIssuerPublicUrlResolver. The/.well-known/openid-credential-issuer/{tenant}document advertises the tenant-boundcredential_issuer, credential endpoint, nonce endpoint, deferred endpoint, and notification endpoint. The credential-offer endpoint producescredential_offer_uriandstatus_urirooted at the same binding. - Verifier (
lib-openid-oid4vp-verifier-rest-tenant). Replaces the IDK Universal OID4VP create-auth-request command. The returned authorization request carriesrequest_uri_baserooted at the tenant's OID4VP base, signed request objects withdirect_postcarryresponse_uriat the tenant's response endpoint, andstatus_uriis rooted at the tenant's OID4VP backend base. - AS (
lib-oauth2-server-rest-tenant). Replaces the IDK OAuth URL resolver. Theissuervalue in metadata, thejwks_uri, theauthorization_endpoint, thetoken_endpoint, theuserinfo_endpoint, theend_session_endpoint, and the federation callback URLs all come from the tenant binding.
REST Surface
The tenant admin REST exposes both entities under the standard TenantAdminHttpAdapter mount at /api/v1/tenants:
# Domains
GET /api/v1/tenants/{tenantId}/domains
POST /api/v1/tenants/{tenantId}/domains
DELETE /api/v1/tenants/{tenantId}/domains/{domainId}
POST /api/v1/tenants/{tenantId}/domains/{domainId}/verify
# Public endpoints
GET /api/v1/tenants/{tenantId}/public-endpoints
PUT /api/v1/tenants/{tenantId}/public-endpoints/{serviceType}
DELETE /api/v1/tenants/{tenantId}/public-endpoints/{serviceType}
The {tenantId} in the path is the operation target. The acting tenant comes from the JWT (TenantPathPolicy.None), so a JWT bound to tenant A asking to mutate tenant B's resources is rejected by the cross-tenant authorisation check, not by the path policy.
upsertTenantPublicEndpoint is a PUT against {serviceType} because at most one binding per (tenant, serviceType) is allowed. The unique constraint is enforced at the database level; the REST adapter additionally rejects payload/path mismatch (asking to PUT against OID4VCI_ISSUER with a body that names OID4VP_VERIFIER fails with a 400).
Hardening
The public-endpoint admin REST is one of the more security-relevant surfaces in the EDK, because a malicious or buggy update can redirect a tenant's wallet flows to an attacker-controlled host. The implemented hardening:
- Cross-tenant mutation rejection. A JWT bound to tenant A cannot mutate tenant B's public-endpoint rows even with knowledge of B's tenant id.
- Unverified domain rejection. The host on a public-endpoint binding must reference a verified
tenant_domainrow for the same tenant. Pointing at an unverified domain is rejected. - Default-host collision rejection. A binding cannot point at the deployment's default host in a way that would shadow another tenant's primary.
- Duplicate rejection. The unique
(tenant_id, service_type)index plus the REST-layer pre-check prevent two enabled rows for the same service. - Route / body mismatch rejection. The service type in the path must match the service type in the payload.
The full hardening test suite lives under services-tenant-rest in commonTest and runs against the Postgres TestContainers profile in CI.