SSO Domain Validation chain: dns_txt, url, meta_tag (#5414)

* Implement SSO Domain validation chain

* Use iolists 🆒

* Use aliases

* Update moduledoc

* Update test/plausible/auth/sso/domain/validation_test.exs

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* Update test/plausible/auth/sso/domain/validation_test.exs

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* Update test/plausible/auth/sso/domain/validation_test.exs

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* Match non-empty list for meta tag check

---------

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
This commit is contained in:
hq1 2025-06-03 17:07:57 +02:00 committed by GitHub
parent 7ccaebfee7
commit 6040bed54b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 365 additions and 3 deletions

View File

@ -17,12 +17,14 @@ defmodule Plausible.Auth.SSO.Domain do
alias Plausible.Auth.SSO
@validation_methods [:dns_txt, :url, :meta_tag]
@type t() :: %__MODULE__{}
@validation_methods [:dns_txt, :url, :meta_tag]
@type validation_method() :: unquote(Enum.reduce(@validation_methods, &{:|, [], [&1, &2]}))
@spec validation_methods() :: list(validation_method())
def validation_methods(), do: @validation_methods
schema "sso_domains" do
field :identifier, Ecto.UUID
field :domain, :string

View File

@ -0,0 +1,133 @@
defmodule Plausible.Auth.SSO.Domain.Validation do
@moduledoc """
SSO domain validation chain
1. DNS TXT `{domain}` record lookup.
Successful expectation contains `plausible-sso-verification={domain-identifier}` record.
2. HTTP GET lookup at `https://{domain}/plausible-sso-verification`
Successful expectation contains `{domain-identifier}` in the body.
3. META tag lookup at `https://{domain}`
Successful expectation contains:
```html
<meta name="plausible-sso-verification" content="{domain-identifier}">
```
in the body of `text/html` type.
"""
alias Plausible.Auth.SSO.Domain
require Domain
@prefix "plausible-sso-verification"
@spec run(String.t(), String.t(), Keyword.t()) ::
{:ok, Domain.validation_method()} | {:error, :invalid}
def run(sso_domain, domain_identifier, opts \\ []) do
available_methods = Domain.validation_methods()
methods = Keyword.get(opts, :methods, available_methods)
true = Enum.all?(methods, &(&1 in available_methods))
Enum.reduce_while(methods, {:error, :invalid}, fn method, acc ->
case apply(__MODULE__, method, [sso_domain, domain_identifier, opts]) do
true -> {:halt, {:ok, method}}
false -> {:cont, acc}
end
end)
end
@spec url(String.t(), String.t(), Keyword.t()) :: boolean()
def url(sso_domain, domain_identifier, opts \\ []) do
url_override = Keyword.get(opts, :url_override)
resp = run_request(url_override || "https://" <> Path.join(sso_domain, @prefix))
case resp do
%Req.Response{body: body}
when is_binary(body) ->
String.trim(body) == domain_identifier
_ ->
false
end
end
@spec meta_tag(String.t(), String.t(), Keyword.t()) :: boolean()
def meta_tag(sso_domain, domain_identifier, opts \\ []) do
url_override = Keyword.get(opts, :url_override)
with %Req.Response{body: body} = response when is_binary(body) <-
run_request(url_override || "https://#{sso_domain}"),
true <- html?(response),
{:ok, html} <- Floki.parse_document(body),
[_ | _] <- Floki.find(html, ~s|meta[name="#{@prefix}"][content="#{domain_identifier}"]|) do
true
else
_ ->
false
end
end
@spec dns_txt(String.t(), String.t()) :: boolean()
def dns_txt(sso_domain, domain_identifier, opts \\ []) do
record_value = to_charlist("#{@prefix}=#{domain_identifier}")
timeout = Keyword.get(opts, :timeout, 5_000)
nameservers = Keyword.get(opts, :nameservers, [])
opts = [timeout: timeout, nameservers: nameservers]
sso_domain
|> to_charlist()
|> :inet_res.lookup(:in, :txt, opts, timeout)
|> Enum.find_value(false, fn
[^record_value] -> true
_ -> false
end)
end
defp html?(%Req.Response{headers: headers}) do
headers
|> Map.get("content-type", "")
|> List.wrap()
|> List.first()
|> String.contains?("text/html")
end
defp run_request(base_url) do
fetch_body_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
opts =
Keyword.merge(
[
base_url: base_url,
max_redirects: 4,
max_retries: 3,
retry_log_level: :warning
],
fetch_body_opts
)
{_req, resp} = opts |> Req.new() |> Req.Request.run_request()
resp
end
@after_compile __MODULE__
def __after_compile__(_env, _bytecode) do
available_methods = Domain.validation_methods()
exported_funs =
:functions
|> __MODULE__.__info__()
|> Enum.map(&elem(&1, 0))
Enum.each(
available_methods,
fn method ->
if method not in exported_funs do
raise "#{method} must be implemented in #{__MODULE__}"
end
end
)
end
end

View File

@ -19,11 +19,20 @@ defmodule Plausible.Auth.SSO.Domains do
@spec verify(SSO.Domain.t(), Keyword.t()) :: SSO.Domain.t()
def verify(sso_domain, opts \\ []) do
skip_checks? = Keyword.get(opts, :skip_checks?, false)
verification_opts = Keyword.get(opts, :verification_opts, [])
now = Keyword.get(opts, :now, NaiveDateTime.utc_now(:second))
if skip_checks? do
mark_valid(sso_domain, :dns_txt, now)
else
case SSO.Domain.Validation.run(sso_domain.domain, sso_domain.identifier, verification_opts) do
{:ok, step} ->
mark_valid(sso_domain, step, now)
{:error, :invalid} ->
mark_invalid(sso_domain, now)
end
mark_invalid(sso_domain, now)
end
end

View File

@ -0,0 +1,153 @@
defmodule Plausible.Auth.SSO.Domain.ValidationTest do
use Plausible.DataCase, async: true
use Plausible
@moduletag :ee_only
on_ee do
use Plausible.Teams.Test
alias Plasusible.Test.Support.DNSServer
alias Plausible.Auth.SSO.Domain.Validation
alias Plug.Conn
setup do
team = new_site().team
bypass = Bypass.open()
{:ok, team: team, bypass: bypass}
end
describe "individual checks" do
test "dns_txt" do
{:ok, port} = DNSServer.start("plausible-sso-verification=ex4mpl3")
refute Validation.dns_txt("example.com", "failing-identifier",
nameservers: [{{0, 0, 0, 0}, port}]
)
assert Validation.dns_txt("example.com", "ex4mpl3", nameservers: [{{0, 0, 0, 0}, port}])
end
test "url", %{bypass: bypass} do
Bypass.expect(bypass, "GET", "/test", fn conn ->
Conn.resp(conn, 200, "ex4mpl3")
end)
refute Validation.url("example.com", "failing-identifier",
url_override: "http://localhost:#{bypass.port}/test"
)
assert Validation.url("example.com", "ex4mpl3",
url_override: "http://localhost:#{bypass.port}/test"
)
end
test "meta_tag", %{bypass: bypass} do
Bypass.expect(bypass, "GET", "/test", fn conn ->
conn
|> Conn.put_resp_header("content-type", "text/html")
|> Conn.resp(200, """
<html>
<meta name="plausible-sso-verification" content="ex4mpl3"/>
</html>
""")
end)
refute Validation.meta_tag("example.com", "failing-identifier",
url_override: "http://localhost:#{bypass.port}/test"
)
assert Validation.meta_tag("example.com", "ex4mpl3",
url_override: "http://localhost:#{bypass.port}/test"
)
end
test "meta-tag fails on non-html", %{bypass: bypass} do
Bypass.expect_once(bypass, "GET", "/test", fn conn ->
Conn.resp(conn, 200, """
<html>
<meta name="plausible-sso-verification" content="ex4mpl3"/>
</html>
""")
end)
refute Validation.meta_tag("example.com", "ex4mpl3",
url_override: "http://localhost:#{bypass.port}/test"
)
end
test "meta-tag fails on parse failure", %{bypass: bypass} do
Bypass.expect_once(bypass, "GET", "/test", fn conn ->
conn
|> Conn.put_resp_header("content-type", "text/html")
|> Conn.resp(200, """
meta name="plausible-sso-verification" content="ex4mpl3
""")
end)
refute Validation.meta_tag("example.com", "ex4mpl3",
url_override: "http://localhost:#{bypass.port}/test"
)
end
test "meta_tag succeeds in case of multiple matches", %{bypass: bypass} do
Bypass.expect(bypass, "GET", "/test", fn conn ->
conn
|> Conn.put_resp_header("content-type", "text/html")
|> Conn.resp(200, """
<html>
<meta name="plausible-sso-verification" content="ex4mpl3"/>
<meta name="plausible-sso-verification" content="ex4mpl3"/>
</html>
""")
end)
assert Validation.meta_tag("example.com", "ex4mpl3",
url_override: "http://localhost:#{bypass.port}/test"
)
end
end
describe "all methods" do
test "DNS matches, no HTTP endpoint is ever called", %{bypass: bypass} do
{:ok, dns_port} = DNSServer.start("plausible-sso-verification=ex4mpl3")
Bypass.stub(bypass, "GET", "/", fn _conn -> raise "should never be called" end)
assert {:ok, :dns_txt} =
Validation.run("example.com", "ex4mpl3",
url_override: "http://localhost:#{bypass.port}/",
nameservers: [{{0, 0, 0, 0}, dns_port}]
)
end
test "DNS fails to match, url check succeeds", %{bypass: bypass} do
Bypass.expect_once(bypass, "GET", "/", fn conn ->
Conn.resp(conn, 200, "ex4mpl3")
end)
assert {:ok, :url} =
Validation.run("example.com", "ex4mpl3",
url_override: "http://localhost:#{bypass.port}/"
)
end
test "DNS and url checks fail to match, meta tag check succeeds", %{bypass: bypass} do
Bypass.expect(bypass, "GET", "/", fn conn ->
conn
|> Conn.put_resp_header("content-type", "text/html")
|> Conn.resp(
200,
"<html><meta name=\"plausible-sso-verification\" content=\"ex4mpl3\"/></html>"
)
end)
assert {:ok, :meta_tag} =
Validation.run("example.com", "ex4mpl3",
url_override: "http://localhost:#{bypass.port}/"
)
end
end
end
end

View File

@ -2,6 +2,8 @@ defmodule Plausible.Auth.SSO.DomainsTest do
use Plausible.DataCase, async: true
use Plausible
@moduletag :ee_only
on_ee do
use Plausible.Teams.Test
@ -117,7 +119,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do
domain = generate_domain()
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
invalid_domain = SSO.Domains.verify(sso_domain)
invalid_domain = SSO.Domains.verify(sso_domain, verification_opts: [methods: []])
assert invalid_domain.id == sso_domain.id
refute invalid_domain.validated_via

View File

@ -0,0 +1,63 @@
defmodule Plasusible.Test.Support.DNSServer do
@moduledoc """
A simple DNS server that responds to TXT queries with fixed sample values.
"""
def start(fixed_response) do
{:ok, socket} = :gen_udp.open(0, [:binary, active: true, ip: {0, 0, 0, 0}])
{:ok, port} = :inet.port(socket)
child = spawn(fn -> loop(socket, fixed_response) end)
:ok = :gen_udp.controlling_process(socket, child)
{:ok, port}
end
defp loop(socket, fixed_response) do
receive do
{:udp, _socket, client_ip, client_port, query} ->
response = build_response(query, fixed_response)
:gen_udp.send(socket, client_ip, client_port, response)
loop(socket, fixed_response)
end
end
defp build_response(query, response) do
<<transaction_id::16, _flags::16, qdcount::16, _rest::binary>> = query
header = <<
# Transaction ID
transaction_id::16,
# Flags: Standard query response, no error
0b10000100_00000000::16,
# Questions: Echo the number of questions
qdcount::16,
# Answer RRs: 1
1::16,
# Authority RRs: 0
0::16,
# Additional RRs: 0
0::16
>>
<<_header::binary-size(12), question::binary>> = query
txt_data = encode_txt_data(response)
answer = <<
# Name: Pointer to the question
0xC00C::16,
# Type: TXT
16::16,
# Class: IN (Internet)
1::16,
# TTL: 60 seconds
60::32,
byte_size(txt_data)::16,
txt_data::binary
>>
[header, question, answer]
end
defp encode_txt_data(txt_value) do
<<byte_size(txt_value)::8, txt_value::binary>>
end
end