An Elixir client for the Domain Connect protocol — the open standard (now IETF-track) for one-click DNS setup so a non-technical domain owner can point a custom domain at your app without ever touching a CNAME record.

When someone connects rent.theirplace.com to your SaaS, instead of "create a CNAME with host rent and value portal.yourapp.com, then wait for DNS," they click a button, consent at their own DNS provider, and the records are applied. ~20 providers implement it, including GoDaddy, IONOS, Cloudflare, Squarespace Domains, WordPress.com, and Plesk (≈35% of the .com zone).

Status

Covers the Service-Provider side of both flows:

  • Discovery — PSL registrable-zone resolution → _domainconnect TXT lookup → provider /settings fetch (SSRF-guarded).
  • Synchronous flowapply_url/2 builds the "apply this template" URL to redirect the owner to.
  • Asynchronous (OAuth) flowasync_consent_url/2async_token/2async_apply/3, plus async_refresh/2, for applying templates programmatically.
  • Signed templates — pass :private_key (RSA PEM) + :key_id to sign the apply request (RSA-SHA256) on either flow.

Every request this library makes server-side (settings fetch, token exchange, apply, refresh) is SSRF-guarded: the DNS-influenced host is validated as a public hostname (no IP literals, no private/loopback/link-local/ULA addresses), redirects are disabled, and timeouts are tight.

Install

def deps do
  [{:domain_connect, "~> 0.5"}]
end

Usage

# 1. Does this domain's DNS provider support Domain Connect, and how?
{:ok, config} = DomainConnect.discover("rent.theirplace.com")
#=> %DomainConnect.Config{domain: "theirplace.com", host: "rent",
#     provider_id: "GoDaddy", url_sync_ux: "https://dcc.godaddy.com/manage", ...}

# 2. Build the URL to send the owner to. They click "apply" at their provider,
#    the records land, and the custom domain goes live.
{:ok, url} =
  DomainConnect.apply_url(config,
    provider_id: "yourapp.com",     # YOUR template's providerId
    service_id: "custom-domain",    # YOUR template's serviceId
    params: %{"target" => "portal.yourapp.com"}
  )

# redirect_to(conn, external: url)

provider_id / service_id identify your Domain Connect template — the record set you register with each DNS provider — not the DNS provider itself.

Just checking support

DomainConnect.supported?("rent.theirplace.com")  #=> true | false

Asynchronous (OAuth) flow

For applying templates programmatically instead of a one-shot redirect:

{:ok, config} = DomainConnect.discover("rent.theirplace.com")

# 1. Send the owner to consent.
{:ok, consent_url} =
  DomainConnect.async_consent_url(config,
    provider_id: "yourapp.com",
    service_ids: "custom-domain",            # one id or a list -> OAuth scope
    redirect_uri: "https://yourapp.com/dc/callback",
    state: "opaque"
  )

# 2. On the callback, exchange the code for a token.
{:ok, token} =
  DomainConnect.async_token(config,
    code: code, client_id: "yourapp.com", client_secret: secret,
    redirect_uri: "https://yourapp.com/dc/callback"
  )

# 3. Apply the template (idempotent; {:error, :conflict} unless force: true).
:ok =
  DomainConnect.async_apply(config, token,
    provider_id: "yourapp.com", service_id: "custom-domain",
    params: %{"target" => "portal.yourapp.com"}
  )

# Later: DomainConnect.async_refresh(config, refresh_token: token.refresh_token, ...)

How it works

  1. Discovery. Compute the registrable zone via the Public Suffix List (so rent.theirplace.co.uk → zone theirplace.co.uk, host rent), query TXT _domainconnect.<zone> for the provider's API host, SSRF-check that host, then GET https://<api-host>/v2/<zone>/settings for the provider's URLs.
  2. Apply. Build <url_sync_ux>/v2/domainTemplates/providers/<provider_id>/services/<service_id>/apply?domain=…&host=…&<vars> and redirect the owner there.

The DNS resolver, address resolver (SSRF guard), and HTTP client are injectable for testing (:resolver, :address_resolver, :req_options on discover/2).

Limitations

  • Registrable zone only. Discovery uses the PSL registrable domain (matching the reference library). A record published on a delegated sub-zone isn't found.
  • No template-support probe. apply_url/2 builds the URL; it doesn't call the provider API to confirm the template is supported. An unsupported template surfaces only at the provider's consent screen.
  • ASCII / punycode domains. Pass already-encoded (punycode) domains; IDNA conversion of Unicode domains isn't performed.

The template-registration caveat

The library builds correct requests for any provider. To actually light up a given provider in production, you register your template (the records your service needs) with that provider — GoDaddy, IONOS, etc. each have a template onboarding step. That's operational, per-provider, and one-time; it isn't a prerequisite for using this library or for the providers you've onboarded.

License

MIT