Skip to main content
Version: v0.25.0 (Latest)

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 when initialPlatformSubdomain = 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 as wallet.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:

  1. The tenant administrator adds the domain via POST /api/v1/tenants/{tenantId}/domains with kind CUSTOM_DOMAIN. The EDK mints a verificationToken and stores the row unverified.
  2. The administrator publishes the verification token as a DNS TXT record (the exact record format is defined by the deployment).
  3. The verification worker (or the operator-triggered POST /api/v1/tenants/{tenantId}/domains/{domainId}/verify) checks DNS, and on success calls MarkDomainVerifiedHttpEndpointCommand. The verifiedAt timestamp 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.com is automatically primary.
  • Customer migrates to a custom domain: the operator marks wallet.acme.com primary, 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_endpoint binding.

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 of OID4VCI_ISSUER, OID4VP_VERIFIER, or OAUTH2_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 verified tenant_domain row for the same tenant. When null, the endpoint uses the runtime host or the default base.
  • pathPrefix. The protocol route prefix, for example /acme/oid4vci for 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/acme for 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 IDK Oid4vciIssuerPublicUrlResolver with TenantPublicEndpointOid4vciIssuerPublicUrlResolver. The /.well-known/openid-credential-issuer/{tenant} document advertises the tenant-bound credential_issuer, credential endpoint, nonce endpoint, deferred endpoint, and notification endpoint. The credential-offer endpoint produces credential_offer_uri and status_uri rooted at the same binding.
  • Verifier (lib-openid-oid4vp-verifier-rest-tenant). Replaces the IDK Universal OID4VP create-auth-request command. The returned authorization request carries request_uri_base rooted at the tenant's OID4VP base, signed request objects with direct_post carry response_uri at the tenant's response endpoint, and status_uri is rooted at the tenant's OID4VP backend base.
  • AS (lib-oauth2-server-rest-tenant). Replaces the IDK OAuth URL resolver. The issuer value in metadata, the jwks_uri, the authorization_endpoint, the token_endpoint, the userinfo_endpoint, the end_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_domain row 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.