Skip to content

Commit 6040bed

Browse files
aerosolzoldar
andauthored
SSO Domain Validation chain: dns_txt, url, meta_tag (plausible#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>
1 parent 7ccaebf commit 6040bed

6 files changed

Lines changed: 365 additions & 3 deletions

File tree

extra/lib/plausible/auth/sso/domain.ex

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ defmodule Plausible.Auth.SSO.Domain do
1717

1818
alias Plausible.Auth.SSO
1919

20-
@validation_methods [:dns_txt, :url, :meta_tag]
21-
2220
@type t() :: %__MODULE__{}
2321

22+
@validation_methods [:dns_txt, :url, :meta_tag]
2423
@type validation_method() :: unquote(Enum.reduce(@validation_methods, &{:|, [], [&1, &2]}))
2524

25+
@spec validation_methods() :: list(validation_method())
26+
def validation_methods(), do: @validation_methods
27+
2628
schema "sso_domains" do
2729
field :identifier, Ecto.UUID
2830
field :domain, :string
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
defmodule Plausible.Auth.SSO.Domain.Validation do
2+
@moduledoc """
3+
SSO domain validation chain
4+
5+
1. DNS TXT `{domain}` record lookup.
6+
Successful expectation contains `plausible-sso-verification={domain-identifier}` record.
7+
8+
2. HTTP GET lookup at `https://{domain}/plausible-sso-verification`
9+
Successful expectation contains `{domain-identifier}` in the body.
10+
11+
3. META tag lookup at `https://{domain}`
12+
Successful expectation contains:
13+
14+
```html
15+
<meta name="plausible-sso-verification" content="{domain-identifier}">
16+
```
17+
18+
in the body of `text/html` type.
19+
"""
20+
21+
alias Plausible.Auth.SSO.Domain
22+
require Domain
23+
24+
@prefix "plausible-sso-verification"
25+
26+
@spec run(String.t(), String.t(), Keyword.t()) ::
27+
{:ok, Domain.validation_method()} | {:error, :invalid}
28+
def run(sso_domain, domain_identifier, opts \\ []) do
29+
available_methods = Domain.validation_methods()
30+
methods = Keyword.get(opts, :methods, available_methods)
31+
true = Enum.all?(methods, &(&1 in available_methods))
32+
33+
Enum.reduce_while(methods, {:error, :invalid}, fn method, acc ->
34+
case apply(__MODULE__, method, [sso_domain, domain_identifier, opts]) do
35+
true -> {:halt, {:ok, method}}
36+
false -> {:cont, acc}
37+
end
38+
end)
39+
end
40+
41+
@spec url(String.t(), String.t(), Keyword.t()) :: boolean()
42+
def url(sso_domain, domain_identifier, opts \\ []) do
43+
url_override = Keyword.get(opts, :url_override)
44+
resp = run_request(url_override || "https://" <> Path.join(sso_domain, @prefix))
45+
46+
case resp do
47+
%Req.Response{body: body}
48+
when is_binary(body) ->
49+
String.trim(body) == domain_identifier
50+
51+
_ ->
52+
false
53+
end
54+
end
55+
56+
@spec meta_tag(String.t(), String.t(), Keyword.t()) :: boolean()
57+
def meta_tag(sso_domain, domain_identifier, opts \\ []) do
58+
url_override = Keyword.get(opts, :url_override)
59+
60+
with %Req.Response{body: body} = response when is_binary(body) <-
61+
run_request(url_override || "https://#{sso_domain}"),
62+
true <- html?(response),
63+
{:ok, html} <- Floki.parse_document(body),
64+
[_ | _] <- Floki.find(html, ~s|meta[name="#{@prefix}"][content="#{domain_identifier}"]|) do
65+
true
66+
else
67+
_ ->
68+
false
69+
end
70+
end
71+
72+
@spec dns_txt(String.t(), String.t()) :: boolean()
73+
def dns_txt(sso_domain, domain_identifier, opts \\ []) do
74+
record_value = to_charlist("#{@prefix}=#{domain_identifier}")
75+
76+
timeout = Keyword.get(opts, :timeout, 5_000)
77+
nameservers = Keyword.get(opts, :nameservers, [])
78+
opts = [timeout: timeout, nameservers: nameservers]
79+
80+
sso_domain
81+
|> to_charlist()
82+
|> :inet_res.lookup(:in, :txt, opts, timeout)
83+
|> Enum.find_value(false, fn
84+
[^record_value] -> true
85+
_ -> false
86+
end)
87+
end
88+
89+
defp html?(%Req.Response{headers: headers}) do
90+
headers
91+
|> Map.get("content-type", "")
92+
|> List.wrap()
93+
|> List.first()
94+
|> String.contains?("text/html")
95+
end
96+
97+
defp run_request(base_url) do
98+
fetch_body_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
99+
100+
opts =
101+
Keyword.merge(
102+
[
103+
base_url: base_url,
104+
max_redirects: 4,
105+
max_retries: 3,
106+
retry_log_level: :warning
107+
],
108+
fetch_body_opts
109+
)
110+
111+
{_req, resp} = opts |> Req.new() |> Req.Request.run_request()
112+
resp
113+
end
114+
115+
@after_compile __MODULE__
116+
def __after_compile__(_env, _bytecode) do
117+
available_methods = Domain.validation_methods()
118+
119+
exported_funs =
120+
:functions
121+
|> __MODULE__.__info__()
122+
|> Enum.map(&elem(&1, 0))
123+
124+
Enum.each(
125+
available_methods,
126+
fn method ->
127+
if method not in exported_funs do
128+
raise "#{method} must be implemented in #{__MODULE__}"
129+
end
130+
end
131+
)
132+
end
133+
end

extra/lib/plausible/auth/sso/domains.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,20 @@ defmodule Plausible.Auth.SSO.Domains do
1919
@spec verify(SSO.Domain.t(), Keyword.t()) :: SSO.Domain.t()
2020
def verify(sso_domain, opts \\ []) do
2121
skip_checks? = Keyword.get(opts, :skip_checks?, false)
22+
verification_opts = Keyword.get(opts, :verification_opts, [])
2223
now = Keyword.get(opts, :now, NaiveDateTime.utc_now(:second))
2324

2425
if skip_checks? do
2526
mark_valid(sso_domain, :dns_txt, now)
2627
else
28+
case SSO.Domain.Validation.run(sso_domain.domain, sso_domain.identifier, verification_opts) do
29+
{:ok, step} ->
30+
mark_valid(sso_domain, step, now)
31+
32+
{:error, :invalid} ->
33+
mark_invalid(sso_domain, now)
34+
end
35+
2736
mark_invalid(sso_domain, now)
2837
end
2938
end
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
defmodule Plausible.Auth.SSO.Domain.ValidationTest do
2+
use Plausible.DataCase, async: true
3+
use Plausible
4+
5+
@moduletag :ee_only
6+
7+
on_ee do
8+
use Plausible.Teams.Test
9+
10+
alias Plasusible.Test.Support.DNSServer
11+
alias Plausible.Auth.SSO.Domain.Validation
12+
alias Plug.Conn
13+
14+
setup do
15+
team = new_site().team
16+
bypass = Bypass.open()
17+
18+
{:ok, team: team, bypass: bypass}
19+
end
20+
21+
describe "individual checks" do
22+
test "dns_txt" do
23+
{:ok, port} = DNSServer.start("plausible-sso-verification=ex4mpl3")
24+
25+
refute Validation.dns_txt("example.com", "failing-identifier",
26+
nameservers: [{{0, 0, 0, 0}, port}]
27+
)
28+
29+
assert Validation.dns_txt("example.com", "ex4mpl3", nameservers: [{{0, 0, 0, 0}, port}])
30+
end
31+
32+
test "url", %{bypass: bypass} do
33+
Bypass.expect(bypass, "GET", "/test", fn conn ->
34+
Conn.resp(conn, 200, "ex4mpl3")
35+
end)
36+
37+
refute Validation.url("example.com", "failing-identifier",
38+
url_override: "http://localhost:#{bypass.port}/test"
39+
)
40+
41+
assert Validation.url("example.com", "ex4mpl3",
42+
url_override: "http://localhost:#{bypass.port}/test"
43+
)
44+
end
45+
46+
test "meta_tag", %{bypass: bypass} do
47+
Bypass.expect(bypass, "GET", "/test", fn conn ->
48+
conn
49+
|> Conn.put_resp_header("content-type", "text/html")
50+
|> Conn.resp(200, """
51+
<html>
52+
<meta name="plausible-sso-verification" content="ex4mpl3"/>
53+
</html>
54+
""")
55+
end)
56+
57+
refute Validation.meta_tag("example.com", "failing-identifier",
58+
url_override: "http://localhost:#{bypass.port}/test"
59+
)
60+
61+
assert Validation.meta_tag("example.com", "ex4mpl3",
62+
url_override: "http://localhost:#{bypass.port}/test"
63+
)
64+
end
65+
66+
test "meta-tag fails on non-html", %{bypass: bypass} do
67+
Bypass.expect_once(bypass, "GET", "/test", fn conn ->
68+
Conn.resp(conn, 200, """
69+
<html>
70+
<meta name="plausible-sso-verification" content="ex4mpl3"/>
71+
</html>
72+
""")
73+
end)
74+
75+
refute Validation.meta_tag("example.com", "ex4mpl3",
76+
url_override: "http://localhost:#{bypass.port}/test"
77+
)
78+
end
79+
80+
test "meta-tag fails on parse failure", %{bypass: bypass} do
81+
Bypass.expect_once(bypass, "GET", "/test", fn conn ->
82+
conn
83+
|> Conn.put_resp_header("content-type", "text/html")
84+
|> Conn.resp(200, """
85+
meta name="plausible-sso-verification" content="ex4mpl3
86+
""")
87+
end)
88+
89+
refute Validation.meta_tag("example.com", "ex4mpl3",
90+
url_override: "http://localhost:#{bypass.port}/test"
91+
)
92+
end
93+
94+
test "meta_tag succeeds in case of multiple matches", %{bypass: bypass} do
95+
Bypass.expect(bypass, "GET", "/test", fn conn ->
96+
conn
97+
|> Conn.put_resp_header("content-type", "text/html")
98+
|> Conn.resp(200, """
99+
<html>
100+
<meta name="plausible-sso-verification" content="ex4mpl3"/>
101+
<meta name="plausible-sso-verification" content="ex4mpl3"/>
102+
</html>
103+
""")
104+
end)
105+
106+
assert Validation.meta_tag("example.com", "ex4mpl3",
107+
url_override: "http://localhost:#{bypass.port}/test"
108+
)
109+
end
110+
end
111+
112+
describe "all methods" do
113+
test "DNS matches, no HTTP endpoint is ever called", %{bypass: bypass} do
114+
{:ok, dns_port} = DNSServer.start("plausible-sso-verification=ex4mpl3")
115+
116+
Bypass.stub(bypass, "GET", "/", fn _conn -> raise "should never be called" end)
117+
118+
assert {:ok, :dns_txt} =
119+
Validation.run("example.com", "ex4mpl3",
120+
url_override: "http://localhost:#{bypass.port}/",
121+
nameservers: [{{0, 0, 0, 0}, dns_port}]
122+
)
123+
end
124+
125+
test "DNS fails to match, url check succeeds", %{bypass: bypass} do
126+
Bypass.expect_once(bypass, "GET", "/", fn conn ->
127+
Conn.resp(conn, 200, "ex4mpl3")
128+
end)
129+
130+
assert {:ok, :url} =
131+
Validation.run("example.com", "ex4mpl3",
132+
url_override: "http://localhost:#{bypass.port}/"
133+
)
134+
end
135+
136+
test "DNS and url checks fail to match, meta tag check succeeds", %{bypass: bypass} do
137+
Bypass.expect(bypass, "GET", "/", fn conn ->
138+
conn
139+
|> Conn.put_resp_header("content-type", "text/html")
140+
|> Conn.resp(
141+
200,
142+
"<html><meta name=\"plausible-sso-verification\" content=\"ex4mpl3\"/></html>"
143+
)
144+
end)
145+
146+
assert {:ok, :meta_tag} =
147+
Validation.run("example.com", "ex4mpl3",
148+
url_override: "http://localhost:#{bypass.port}/"
149+
)
150+
end
151+
end
152+
end
153+
end

test/plausible/auth/sso/domains_test.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ defmodule Plausible.Auth.SSO.DomainsTest do
22
use Plausible.DataCase, async: true
33
use Plausible
44

5+
@moduletag :ee_only
6+
57
on_ee do
68
use Plausible.Teams.Test
79

@@ -117,7 +119,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do
117119
domain = generate_domain()
118120
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
119121

120-
invalid_domain = SSO.Domains.verify(sso_domain)
122+
invalid_domain = SSO.Domains.verify(sso_domain, verification_opts: [methods: []])
121123

122124
assert invalid_domain.id == sso_domain.id
123125
refute invalid_domain.validated_via

0 commit comments

Comments
 (0)