auth: read the login off Remote-User alone
The header was a setting so tailscale serve could name its own;
Caddy writes Remote-User now, so the knob was a 401 left on loan.
The refusal names the one header, and env_key is gone,
since a single fixed name leaves nothing to spell out from one.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change nozpsyxsknnmrpnlrtxoqlkzoxrxrkps
commit 998cef34291bba1e9d14acb61675b793cc3c1512
author Alpha Chen <alpha@kejadlen.dev>
date
parent wouqtwvv
diff --git a/AGENTS.md b/AGENTS.md
index 169d735..6abba31 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -96,12 +96,10 @@ not a fixture; edit a `.vcf` to change what the replay serves.
   `DavXml`, whose methods are the only elements the server can send. Inside
   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 `Remote-User`
-  (`PRO_TACTS_IDENTITY_HEADER` renames it, and the refusal says which
-  name it read). 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.
+- Every request needs a `Remote-User` header or it gets a 401, so a bare
+  `curl` against `rake dev` is refused until you pass it. 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/README.md b/README.md
index 27bcc82..5ac9621 100644
--- a/README.md
+++ b/README.md
@@ -58,11 +58,9 @@ change log the sync tokens count on is written with it, in one
 transaction.
 
 Requests are authenticated by one request header carrying the
-requester's login: `Remote-User`, unless `PRO_TACTS_IDENTITY_HEADER`
-names another. The proxy in front of the app writes that header.
-`tailscale serve` writes `Tailscale-User-Login` and strips it from
-incoming requests, and a Caddy site's `header_up` overwrites whatever
-arrived, so a client cannot forge either one. A request without a login
+requester's login: `Remote-User`. The proxy in front of the app writes
+that header. A Caddy site's `header_up` overwrites whatever arrived, so
+a client cannot forge it. A request without a login
 gets a 401. That holds only while the app is reachable through such a
 proxy alone — bind it to localhost.
 Tailscale documents two cases that carry no identity and so cannot get in:
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index 9ed510a..e37e755 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -61,17 +61,6 @@ module ProTacts
       Pathname.new(@env.fetch("PRO_TACTS_EXCHANGE_LOG", data_dir / "log/exchange.log"))
     end
 
-    # The request header a requester's login is read off,
-    # PRO_TACTS_IDENTITY_HEADER — the whole of the identity this app has
-    # (see ProTacts::ProxyAuth). A deployment names the header its own
-    # proxy writes: `tailscale serve` writes Tailscale-User-Login, and a
-    # Caddy site writes whatever its header_up names. The default is what
-    # a reverse proxy conventionally writes the authenticated user to.
-    #: () -> String
-    def identity_header
-      @env.fetch("PRO_TACTS_IDENTITY_HEADER", "Remote-User")
-    end
-
     # The CardDAV account's display name, PRO_TACTS_PROFILE_NAME — what
     # a device labels the account it installs, and the one way to tell
     # two pro-tacts accounts apart on the same phone. nil when unset
diff --git a/lib/pro_tacts/proxy_auth.rb b/lib/pro_tacts/proxy_auth.rb
index db23f22..4523650 100644
--- a/lib/pro_tacts/proxy_auth.rb
+++ b/lib/pro_tacts/proxy_auth.rb
@@ -1,17 +1,14 @@
 require "pro_tacts"
 
 module ProTacts
-  # Who is asking: the login on the one request header the proxy in front
-  # of this app writes it to, named by ProTacts.config.identity_header
-  # (docs/plans/2026-09-15-identity-from-one-header.md).
+  # Who is asking: the login on Remote-User, which the proxy in front of
+  # this app writes (docs/plans/2026-09-15-identity-from-one-header.md).
   #
-  # `tailscale serve` writes Tailscale-User-Login from the tailnet
-  # identity of the calling node and strips it from incoming requests
-  # before proxying, and a Caddy site's `header_up` sets the header it
-  # names, overwriting whatever arrived. Either way a client cannot
-  # supply its own. That makes the header trustworthy, but only behind
-  # such a proxy: reached directly, the app trusts whatever it is
-  # handed. The app must not be listening anywhere but localhost.
+  # The Caddy site's `header_up` sets Remote-User, overwriting whatever
+  # arrived, so a client cannot supply its own. That makes the header
+  # trustworthy, but only behind such a proxy: reached directly, the app
+  # trusts whatever it is handed. The app must not be listening anywhere
+  # but localhost.
   #
   # A request that names nobody is refused at the top of Web's route
   # (Web#unauthorized). That covers the two cases Tailscale documents as
@@ -30,20 +27,14 @@ module ProTacts
     ENCODED_RUN = /#{ENCODED_WORD}(?:[ \t]+(?=#{ENCODED_WORD}))?/ #: Regexp
     private_constant :ENCODED_WORD, :ENCODED_RUN
 
-    # The login on a request, or nil for one that names nobody. The
-    # header is an argument so a caller can read one this deployment is
-    # not configured for; every caller in the app takes the default.
-    #: (Rack::env env, ?header: String) -> String?
-    def self.login(env, header: ProTacts.config.identity_header)
-      login = decode(env[env_key(header)].to_s)&.strip
-      login unless login.nil? || login.empty?
-    end
+    # Remote-User as Rack spells it in the environment.
+    ENV_KEY = "HTTP_REMOTE_USER" #: String
 
-    # Rack's spelling of a request header (the Rack SPEC's environment):
-    # upcased, dashes to underscores, HTTP_ in front.
-    #: (String header) -> String
-    def self.env_key(header)
-      "HTTP_#{header.upcase.tr("-", "_")}"
+    # The login on a request, or nil for one that names nobody.
+    #: (Rack::env env) -> String?
+    def self.login(env)
+      login = decode(env[ENV_KEY].to_s)&.strip
+      login unless login.nil? || login.empty?
     end
 
     # A header value as serve writes it: Go's mime.QEncoding over the
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index fce7edb..d7b7a06 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -109,21 +109,18 @@ module ProTacts
     # 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.
+    # The body names the header that was read, because the failure it
+    # reports is almost always a proxy writing some other one. 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"] = "Proxy-Identity"
       response["Content-Type"] = "text/plain"
       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"
+        "Unauthorized: no login on the Remote-User header.\n" \
+        "The proxy in front of this app writes that header.\n"
       )
       r.halt
     end
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
index e7cc78d..cec37a4 100644
--- a/test/pro_tacts/exchange_fixtures.rb
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -76,7 +76,7 @@ class ExchangeFixtures < Data.define(:directory)
       key = name == "Content-Type" ? "CONTENT_TYPE" : "HTTP_#{name.tr('-', '_').upcase}"
       [key, value]
     }.merge(
-      ProTacts::ProxyAuth.env_key(ProTacts.config.identity_header) => REPLAY_LOGIN,
+      ProTacts::ProxyAuth::ENV_KEY => REPLAY_LOGIN,
     )
   end
 
diff --git a/test/pro_tacts/test_config.rb b/test/pro_tacts/test_config.rb
index 1974ea5..8dc0a5a 100644
--- a/test/pro_tacts/test_config.rb
+++ b/test/pro_tacts/test_config.rb
@@ -58,16 +58,6 @@ class ConfigTest < Minitest::Test
     assert_equal Pathname.new("stderr"), ProTacts::Config.new("PRO_TACTS_EXCHANGE_LOG" => "stderr").exchange_log_path
   end
 
-  def test_identity_header_defaults_to_remote_user
-    assert_equal "Remote-User", ProTacts::Config.new({}).identity_header
-  end
-
-  def test_identity_header_is_overridable
-    config = ProTacts::Config.new("PRO_TACTS_IDENTITY_HEADER" => "Tailscale-User-Name")
-
-    assert_equal "Tailscale-User-Name", config.identity_header
-  end
-
   # nil rather than a default, so the fallback stays Profile's alone.
   def test_profile_name_is_nil_when_unset
     assert_nil ProTacts::Config.new({}).profile_name
diff --git a/test/pro_tacts/test_proxy_auth.rb b/test/pro_tacts/test_proxy_auth.rb
index 459116e..b8efa06 100644
--- a/test/pro_tacts/test_proxy_auth.rb
+++ b/test/pro_tacts/test_proxy_auth.rb
@@ -5,14 +5,10 @@ require "pro_tacts/proxy_auth"
 # What the header says. That a request naming nobody is refused, and how,
 # is Web's (see WebTest).
 class ProxyAuthTest < Minitest::Test
-  # The header these tests configure the read for, passed explicitly so
-  # nothing here depends on what ProTacts.config defaults to.
-  HEADER = "Remote-User"
-
-  def login(value = "alpha@example.com", sent_as: HEADER)
+  def login(value = "alpha@example.com", sent_as: "HTTP_REMOTE_USER")
     env = {} #: Hash[String, String]
-    env[ProTacts::ProxyAuth.env_key(sent_as)] = value unless value.nil?
-    ProTacts::ProxyAuth.login(env, header: HEADER)
+    env[sent_as] = value unless value.nil?
+    ProTacts::ProxyAuth.login(env)
   end
 
   def test_the_header_is_the_login
@@ -32,18 +28,7 @@ class ProxyAuthTest < Minitest::Test
   end
 
   def test_a_login_on_another_header_is_nobody
-    assert_nil login(sent_as: "Tailscale-User-Login")
-  end
-
-  def test_the_header_read_is_the_one_named
-    env = { ProTacts::ProxyAuth.env_key("Tailscale-User-Login") => "alpha@example.com" }
-
-    assert_equal "alpha@example.com", ProTacts::ProxyAuth.login(env, header: "Tailscale-User-Login")
-  end
-
-  def test_a_header_name_is_upcased_and_prefixed
-    assert_equal "HTTP_REMOTE_USER", ProTacts::ProxyAuth.env_key("Remote-User")
-    assert_equal "HTTP_TAILSCALE_USER_LOGIN", ProTacts::ProxyAuth.env_key("tailscale-user-login")
+    assert_nil login(sent_as: "HTTP_TAILSCALE_USER_LOGIN")
   end
 
   # What Go's mime.QEncoding writes for a non-ASCII value, which is how
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 880b057..f08e3a8 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -22,8 +22,7 @@ class WebTest < Minitest::Test
   end
 
   # Every request names a tailnet user; the route refuses without one
-  # (see the refusal tests below). The header is the configured default
-  # (ProTacts::Config#identity_header). The sentry setup
+  # (see the refusal tests below). The sentry setup
   # pins the transport the app's reports land in; teardown clears it.
   def setup
     setup_sentry
@@ -183,15 +182,13 @@ class WebTest < Minitest::Test
     assert_equal "text/plain", last_response["Content-Type"]
   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.
+  # The header read, which is what tells 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