defmodule DomainConnect do
@moduledoc """
Elixir client for the [Domain Connect](https://www.domainconnect.org) protocol.
Domain Connect lets a *Service Provider* (you) set up the DNS a custom domain
needs to point at your app **without** the domain owner having to understand
CNAME/TXT records. The owner clicks "connect", consents at their own DNS
provider, and the records are applied. ~20 providers implement it, including
GoDaddy, IONOS, Cloudflare, Squarespace Domains, WordPress.com, and Plesk.
This library covers the Service Provider side of the **synchronous** flow:
# 1. Discover whether (and how) the domain's DNS provider supports it.
{:ok, config} = DomainConnect.discover("rent.theirplace.com")
# 2. Build the URL to send the owner to. They click "apply" at their
# provider, the records land, your custom domain goes live.
{:ok, url} =
DomainConnect.apply_url(config,
provider_id: "exampleservice.com",
service_id: "rentals",
params: %{"target" => "portal.unitops.app"}
)
`provider_id`/`service_id` identify **your** Domain Connect template (the
record set you register with each DNS provider), not the DNS provider. See
`apply_url/2`.
The asynchronous OAuth flow (programmatic record writes) is supported via
`async_consent_url/2`, `async_token/2`, `async_apply/3`, and `async_refresh/2`
(see `DomainConnect.Async`). Signed templates are supported on both flows by
passing `:private_key` + `:key_id` (see `apply_url/2` and `DomainConnect.Signing`).
"""
alias DomainConnect.{Async, Config, Discovery, Signing, Token}
@doc """
Discovers the Domain Connect configuration for `domain`. See
`DomainConnect.Discovery.discover/2` for options.
"""
@spec discover(String.t(), keyword()) ::
{:ok, Config.t()} | {:error, :not_supported | term()}
defdelegate discover(domain, opts \\ []), to: Discovery
@doc """
Splits a domain into its registrable zone and sub-host via the Public Suffix
List, without any DNS lookup.
iex> DomainConnect.zone_and_host("rent.theirplace.co.uk")
{:ok, "theirplace.co.uk", "rent"}
Useful for showing the owner exactly what you'll configure ("we'll set up
`rent` on `theirplace.co.uk`") before discovery.
"""
@spec zone_and_host(String.t()) ::
{:ok, String.t(), String.t() | nil} | {:error, :invalid_domain}
defdelegate zone_and_host(domain), to: Discovery, as: :registrable
@doc """
Asynchronous (OAuth) flow: build the consent URL to redirect the owner to.
See `DomainConnect.Async.consent_url/2`.
"""
@spec async_consent_url(Config.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
defdelegate async_consent_url(config, opts), to: Async, as: :consent_url
@doc """
Asynchronous flow: exchange an authorization code for a token.
See `DomainConnect.Async.get_token/2`.
"""
@spec async_token(Config.t(), keyword()) :: {:ok, Token.t()} | {:error, term()}
defdelegate async_token(config, opts), to: Async, as: :get_token
@doc """
Asynchronous flow: refresh an access token. See `DomainConnect.Async.refresh/2`.
"""
@spec async_refresh(Config.t(), keyword()) :: {:ok, Token.t()} | {:error, term()}
defdelegate async_refresh(config, opts), to: Async, as: :refresh
@doc """
Asynchronous flow: apply a template using a token.
See `DomainConnect.Async.apply/3`.
"""
@spec async_apply(Config.t(), Token.t(), keyword()) :: :ok | {:error, term()}
defdelegate async_apply(config, token, opts), to: Async, as: :apply
@doc """
Convenience: `true` if `domain`'s provider supports the synchronous apply flow.
Swallows discovery errors into `false`. Accepts the same options as
`discover/2`.
"""
@spec supported?(String.t(), keyword()) :: boolean()
def supported?(domain, opts \\ []) do
case discover(domain, opts) do
{:ok, config} -> Config.sync_supported?(config)
_ -> false
end
end
@doc """
Builds the synchronous apply URL to redirect the domain owner to.
On arrival the DNS provider shows a consent screen for your template and, on
approval, writes the records. Resolves to:
<url_sync_ux>/v2/domainTemplates/providers/<provider_id>/services/<service_id>/apply?...
## Required options
* `:provider_id` — your template's `providerId` (e.g. `"exampleservice.com"`).
* `:service_id` — your template's `serviceId` (e.g. `"hosting"`).
## Optional options
* `:params` — a map of template variable values (string keys), merged into
the query (e.g. `%{"target" => "portal.unitops.app", "ttl" => "3600"}`).
Must not contain the reserved keys `domain`, `host`, `redirect_uri`,
`state`, or `groupId`.
* `:redirect_uri` — where the provider returns the owner after applying.
* `:state` — opaque value echoed back to `:redirect_uri`.
* `:group_ids` — record group ids for partial templates; a list of strings
or a comma-separated string.
Returns `{:error, :sync_not_supported}` if the provider advertised no sync UX,
`{:error, {:missing, key}}` if a required id is absent, or
`{:error, {:reserved_params, keys}}` if `:params` collides with a reserved key.
## Signed templates
For templates that require a signed request, pass `:private_key` (an
unencrypted RSA private-key PEM) and `:key_id` (the TXT record name where the
matching public key is published). The query is signed (RSA-SHA256) and `sig`
/`key` are appended. Without both, an unsigned URL is built.
"""
@spec apply_url(Config.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
def apply_url(%Config{} = config, opts) do
with :ok <- require_sync(config),
{:ok, provider_id} <- fetch(opts, :provider_id),
{:ok, service_id} <- fetch(opts, :service_id),
:ok <- reject_reserved(Keyword.get(opts, :params, %{})),
:ok <- validate_group_ids(Keyword.get(opts, :group_ids)),
{:ok, query} <- Signing.build_query(query(config, opts), opts) do
base =
String.trim_trailing(config.url_sync_ux, "/") <>
"/v2/domainTemplates/providers/#{encode_segment(provider_id)}" <>
"/services/#{encode_segment(service_id)}/apply"
{:ok, base <> "?" <> query}
end
end
@reserved_params ~w(domain host redirect_uri state groupId sig key)
defp require_sync(config) do
if Config.sync_supported?(config), do: :ok, else: {:error, :sync_not_supported}
end
# A non-empty id that is not a bare dot-segment (`.`/`..` would inject a
# relative path component even after segment-encoding).
defp fetch(opts, key) do
case Keyword.fetch(opts, key) do
{:ok, value} when is_binary(value) and value not in ["", ".", ".."] -> {:ok, value}
{:ok, _} -> {:error, {:invalid, key}}
:error -> {:error, {:missing, key}}
end
end
defp reject_reserved(params) when is_map(params) do
case params |> Map.keys() |> Enum.filter(&(to_string(&1) in @reserved_params)) do
[] -> :ok
keys -> {:error, {:reserved_params, keys}}
end
end
defp reject_reserved(_params), do: {:error, {:invalid, :params}}
defp validate_group_ids(nil), do: :ok
defp validate_group_ids(value) when is_binary(value), do: :ok
defp validate_group_ids(value) when is_list(value) do
if Enum.all?(value, &is_binary/1), do: :ok, else: {:error, {:invalid, :group_ids}}
end
defp validate_group_ids(_value), do: {:error, {:invalid, :group_ids}}
# `URI.encode/1` leaves `/ ? #` unescaped, which would let a crafted id rewrite
# the path; encode each id as a strict path segment.
defp encode_segment(value), do: URI.encode(value, &URI.char_unreserved?/1)
# Ordered so the canonical params lead; template params follow.
defp query(config, opts) do
leading =
[domain: config.domain]
|> maybe_put(:host, config.host)
params = opts |> Keyword.get(:params, %{}) |> Enum.into([])
trailing =
[]
|> maybe_put(:redirect_uri, Keyword.get(opts, :redirect_uri))
|> maybe_put(:state, Keyword.get(opts, :state))
|> maybe_put(:groupId, group_ids(Keyword.get(opts, :group_ids)))
leading ++ params ++ trailing
end
defp group_ids(nil), do: nil
defp group_ids(list) when is_list(list), do: Enum.join(list, ",")
defp group_ids(string) when is_binary(string), do: string
defp maybe_put(list, _key, nil), do: list
defp maybe_put(list, _key, ""), do: list
defp maybe_put(list, key, value), do: list ++ [{key, value}]
end