Skip to main content

README.md

# DomainConnect

An Elixir client for the [Domain Connect](https://www.domainconnect.org) protocol —
the open standard (now [IETF-track](https://datatracker.ietf.org/doc/draft-ietf-dconn-domainconnect/))
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 flow** — `apply_url/2` builds the "apply this template" URL to redirect the owner to.
- **Asynchronous (OAuth) flow** — `async_consent_url/2` → `async_token/2` → `async_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

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

## Usage

```elixir
# 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

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

### Asynchronous (OAuth) flow

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

```elixir
{: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