auth: fix the dev header, name it in the refusal
The dev default named a header no proxy ever sends;
Tailscale-User-Name is where this deployment's login ends.
The refusal said "no Tailscale identity" and named nothing at all;
it prints the header it read and the setting that made the call.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/AGENTS.md b/AGENTS.md
index 5ed74fe..e5bba4d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -94,11 +94,12 @@ not a fixture; edit a `.vcf` to change what the replay serves.
an element's block, `it` is the builder, not an enclosing block's value,
so name the outer block's parameter.
- Every request needs the identity header or it gets a 401, so a bare
- `curl` against `rake dev` is refused until you pass `Tailscale-Login`,
- which is what that task sets `PRO_TACTS_IDENTITY_HEADER` to
- (`Remote-User` is the default everywhere else). The security of that
- rests on the app being reachable only through a proxy that writes the
- header itself — never bind it to anything but localhost.
+ `curl` against `rake dev` is refused until you pass
+ `Tailscale-User-Name`, which is what that task sets
+ `PRO_TACTS_IDENTITY_HEADER` to (`Remote-User` is the default everywhere
+ else). The security of that rests on the app being reachable only
+ through a proxy that writes the header itself — never bind it to
+ anything but localhost.
- DAV exchanges that went wrong (a status of 400 or more but 401, a
crash, or a report to Sentry) are written whole to `log/exchange.log`,
each line prefixed by the id Sentry carries as the `exchange` tag.
diff --git a/Rakefile b/Rakefile
index 24fa754..0747cd3 100644
--- a/Rakefile
+++ b/Rakefile
@@ -31,10 +31,11 @@ task :dev do
File.truncate("log/dev.log", 0) if File.exist?("log/dev.log")
# No proxy in front of a dev server, so the identity header is
- # whatever a client passes by hand. Tailscale-Login rather than the
- # Remote-User default because a dev session is testing what the
- # tailnet deployment does (ProTacts::Config#identity_header).
- ENV["PRO_TACTS_IDENTITY_HEADER"] ||= "Tailscale-Login"
+ # whatever a client passes by hand. The name the tailnet deployment's
+ # proxy writes the login to, rather than the Remote-User default, so a
+ # dev session reads the header that deployment reads
+ # (ProTacts::Config#identity_header).
+ ENV["PRO_TACTS_IDENTITY_HEADER"] ||= "Tailscale-User-Name"
Dir.mktmpdir("pro-tacts-dev") do |dir|
data_dir = Pathname.new(dir)
diff --git a/docs/plans/2026-09-15-identity-from-one-header.md b/docs/plans/2026-09-15-identity-from-one-header.md
index 1d43026..edb5e2a 100644
--- a/docs/plans/2026-09-15-identity-from-one-header.md
+++ b/docs/plans/2026-09-15-identity-from-one-header.md
@@ -51,8 +51,13 @@ next piece of work, not part of this one.
`PRO_TACTS_IDENTITY_HEADER` sets it. The default is `Remote-User`, what
a reverse proxy conventionally writes the authenticated user to; the
`docs` site in the same Caddyfile already sends it to another backend.
-`rake dev` sets `Tailscale-Login`, because a dev session has no proxy in
-front of it and is testing what the tailnet deployment does.
+`rake dev` sets `Tailscale-User-Name`, because a dev session has no
+proxy in front of it and is testing what the tailnet deployment does.
+That name is a misnomer inherited from the Caddy site: its `header_up`
+writes `{http.auth.user.tailscale_user}` there, which is the login, not
+the display name the header is named for. The app reads a header, not a
+meaning, so the misnomer costs nothing but a reader's double take —
+correcting it is a Caddyfile change and a resync, not an app change.
The header is trustworthy for the same reason the pair was. `tailscale
serve` strips `Tailscale-User-Login` from an incoming request before it
@@ -60,6 +65,12 @@ writes its own, and Caddy's `header_up` with no `+` prefix sets the
field, overwriting whatever arrived. Neither guarantee survives reaching
the app directly, so it still must not listen anywhere but localhost.
+The 401 names the header it read and the setting that chose it
+(`Web#unauthorized`). A deployment reading one header while its proxy
+writes another is the failure this whole change came out of, and the
+header name is the only thing that tells the two apart; the refusal said
+"no Tailscale identity" before, which named neither.
+
An unset header name is not a case the config handles: the default
covers an absent variable, and a variable set to nothing names no header
and 401s every request, which is the loud failure this repo prefers to a
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index f96b50a..fce7edb 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -104,15 +104,27 @@ module ProTacts
private
# The refusal of a request that names nobody. A 401 must carry a
- # challenge (RFC 9110 section 15.5.2), and `Tailscale` is no
- # registered scheme: it tells the client that the credential is its
- # tailnet identity, which nothing it could send supplies.
+ # challenge (RFC 9110 section 15.5.2), and `Proxy-Identity` is no
+ # registered scheme: it tells the client that the credential is the
+ # login its proxy vouches for, which nothing the client could send
+ # supplies.
+ #
+ # The body names the header that was read and the setting that chose
+ # it, because the failure it reports is almost always a deployment
+ # reading one header while its proxy writes another, and the name is
+ # the whole of what tells them apart. Naming it hands an attacker
+ # nothing: the proxy overwrites the header on every request, so
+ # knowing which one it is buys no way to forge it.
#: (Roda::RodaRequest r) -> bot
def unauthorized(r)
response.status = 401
- response["WWW-Authenticate"] = "Tailscale"
+ response["WWW-Authenticate"] = "Proxy-Identity"
response["Content-Type"] = "text/plain"
- response.write("Unauthorized: no Tailscale identity on this request.\n")
+ response.write(
+ "Unauthorized: no login on the #{ProTacts.config.identity_header} header.\n" \
+ "The proxy in front of this app writes that header; " \
+ "PRO_TACTS_IDENTITY_HEADER names which one is read.\n"
+ )
r.halt
end
diff --git a/test/pro_tacts/test_config.rb b/test/pro_tacts/test_config.rb
index 3e41b87..1974ea5 100644
--- a/test/pro_tacts/test_config.rb
+++ b/test/pro_tacts/test_config.rb
@@ -63,9 +63,9 @@ class ConfigTest < Minitest::Test
end
def test_identity_header_is_overridable
- config = ProTacts::Config.new("PRO_TACTS_IDENTITY_HEADER" => "Tailscale-Login")
+ config = ProTacts::Config.new("PRO_TACTS_IDENTITY_HEADER" => "Tailscale-User-Name")
- assert_equal "Tailscale-Login", config.identity_header
+ assert_equal "Tailscale-User-Name", config.identity_header
end
# nil rather than a default, so the fallback stays Profile's alone.
diff --git a/test/pro_tacts/test_proxy_auth.rb b/test/pro_tacts/test_proxy_auth.rb
index 814476b..459116e 100644
--- a/test/pro_tacts/test_proxy_auth.rb
+++ b/test/pro_tacts/test_proxy_auth.rb
@@ -32,13 +32,13 @@ class ProxyAuthTest < Minitest::Test
end
def test_a_login_on_another_header_is_nobody
- assert_nil login(sent_as: "Tailscale-Login")
+ assert_nil login(sent_as: "Tailscale-User-Login")
end
def test_the_header_read_is_the_one_named
- env = { ProTacts::ProxyAuth.env_key("Tailscale-Login") => "alpha@example.com" }
+ env = { ProTacts::ProxyAuth.env_key("Tailscale-User-Login") => "alpha@example.com" }
- assert_equal "alpha@example.com", ProTacts::ProxyAuth.login(env, header: "Tailscale-Login")
+ assert_equal "alpha@example.com", ProTacts::ProxyAuth.login(env, header: "Tailscale-User-Login")
end
def test_a_header_name_is_upcased_and_prefixed
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 94aa899..880b057 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -174,14 +174,24 @@ class WebTest < Minitest::Test
end
# RFC 9110 section 15.5.2: a 401 carries a challenge.
- def test_a_refusal_names_tailscale_as_the_credential
+ def test_a_refusal_names_the_credential
header "Remote-User", ""
get "/"
- assert_equal "Tailscale", last_response["WWW-Authenticate"]
+ assert_equal "Proxy-Identity", last_response["WWW-Authenticate"]
assert_equal "text/plain", last_response["Content-Type"]
- assert_includes last_response.body, "Tailscale"
+ end
+
+ # The header read and the setting that chose it, which is what tells a
+ # deployment reading one header from a proxy writing another apart.
+ def test_a_refusal_names_the_header_it_read
+ header "Remote-User", ""
+
+ get "/"
+
+ assert_includes last_response.body, "Remote-User"
+ assert_includes last_response.body, "PRO_TACTS_IDENTITY_HEADER"
end
# Every route is gated, not just the address book: an unauthenticated