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>
change pvwxnrxzryupxwvoknkkryurnsvyultv
commit 822f53b080c18f4b3db8d9ef99255fd751a09eac
author Alpha Chen <alpha@kejadlen.dev>
date
parent tsmnnqsk
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