Authenticate on the Tailscale identity header
Serve strips Tailscale-User-Login from incoming requests before setting it
from the tailnet identity, so the header can be trusted, but only behind
serve — reached directly the app trusts whatever it is handed. Failing
closed also shuts out the two cases Tailscale documents as identity-less:
Funnel traffic and tagged devices.

Assisted-by: Claude Opus 5 via Claude Code
change uxvzptttlntlxxxrlvllwnqympotpxyw
commit c8731dc1f4e3172867d1a9e86c37e2fdd4b1e8f4
author Alpha Chen <alpha@kejadlen.dev>
date
parent vmpvvltx
diff --git a/AGENTS.md b/AGENTS.md
index d5f1a86..a39c72a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -60,6 +60,10 @@ before regenerating.
 - Response bodies are built with heredocs in `web.rb`. A comment written
   inside one is sent to the client — keep notes about the code in Ruby
   comments outside the heredoc.
+- Every request needs a `Tailscale-User-Login` header or it gets a 403, so
+  a bare `curl` against `rake dev` is refused until you pass one. The
+  security of that rests on the app being reachable only through
+  `tailscale serve` — never bind it to anything but localhost.
 - `RUBYOPT=--enable-frozen-string-literal` is set in `.ramekin/config.kdl`.
   String literals are frozen; mutating one raises.
 - Application code reads configuration through `ProTacts.config` only; add
diff --git a/README.md b/README.md
index 3ba57c9..b8f9812 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,14 @@ later work has a known-good baseline to change. See
 established. Etags, the ctag, and the sync token are derived from file
 state — a contact's etag hashes its rendered vCard, and the collection
 tags hash the membership — so an edit on disk reaches synced clients on
-their next poll. There is no authentication yet.
+their next poll.
+
+Requests are authenticated by the `Tailscale-User-Login` header that
+`tailscale serve` injects, which it strips from incoming requests so a
+client cannot forge one. A request without it gets a 403. That holds only
+while the app is reachable through serve alone — bind it to localhost.
+Tailscale documents two cases that carry no identity and so cannot get in:
+Funnel traffic, which is public, and traffic from tagged devices.
 
 ## The minimal set macOS Contacts needs
 
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index 08c8410..9defea3 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -16,8 +16,11 @@ module ProTacts
     IDENTIFIER_PREFIX = "dev.kejadlen.pro-tacts.carddav"
     HEX = "0123456789abcdef"
 
-    # Username and password are a throwaway fictional pair, inlined in the
-    # template. Real auth is its own backlog task.
+    # The username and password are a throwaway fictional pair and the server
+    # ignores them: identity comes from the Tailscale headers that serve
+    # injects (see ProTacts::TailscaleAuth). They stay in the template
+    # because the account form expects the fields; dropping them is
+    # untested.
     def self.render(hostname:)
       identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
 
diff --git a/lib/pro_tacts/tailscale_auth.rb b/lib/pro_tacts/tailscale_auth.rb
new file mode 100644
index 0000000..603cfbe
--- /dev/null
+++ b/lib/pro_tacts/tailscale_auth.rb
@@ -0,0 +1,46 @@
+module ProTacts
+  # Gates every request on the identity headers Tailscale serve injects.
+  #
+  # Serve sets Tailscale-User-Login from the tailnet identity of the calling
+  # node, and strips the header from incoming requests before proxying so a
+  # client cannot supply its own. That makes the header trustworthy, but only
+  # behind serve: reached directly, this middleware trusts whatever it is
+  # handed. The app must not be listening anywhere but localhost.
+  #
+  # Failing closed covers the two cases Tailscale documents as having no
+  # identity: Funnel traffic, which is public, and traffic from tagged
+  # devices. A family device that gets tagged will start seeing 403s.
+  #
+  # Any tailnet identity is accepted. Getting onto the tailnet is the access
+  # control; the address book has no per-user view to protect.
+  class TailscaleAuth
+    LOGIN_HEADER = "HTTP_TAILSCALE_USER_LOGIN"
+    NAME_HEADER = "HTTP_TAILSCALE_USER_NAME"
+
+    # Where the authenticated login lands for anything downstream that wants
+    # to know who is asking.
+    IDENTITY = "pro_tacts.user"
+
+    def initialize(app)
+      @app = app
+    end
+
+    def call(env)
+      login = env[LOGIN_HEADER].to_s.strip
+
+      # 403 rather than 401: no credentials the client could supply would
+      # help, so there is no challenge worth sending.
+      return forbidden if login.empty?
+
+      env[IDENTITY] = login
+      @app.call(env)
+    end
+
+    private
+
+    def forbidden
+      body = "Forbidden: no Tailscale identity on this request.\n"
+      [403, { "Content-Type" => "text/plain", "Content-Length" => body.bytesize.to_s }, [body]]
+    end
+  end
+end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 4437c93..ef72f21 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -8,6 +8,7 @@ require "roda"
 
 require "pro_tacts/debug_logger"
 require "pro_tacts/addressbook"
+require "pro_tacts/tailscale_auth"
 require "roda/plugins/dav_verbs"
 
 module ProTacts
@@ -16,6 +17,11 @@ module ProTacts
     # and then rewind it so the application can still access it.
     use Rack::RewindableInput::Middleware
     use Sentry::Rack::CaptureExceptions
+
+    # Ahead of the debug logger on purpose: an unauthenticated request should
+    # not get its body dumped to the log.
+    use ProTacts::TailscaleAuth
+
     if ProTacts.config.debug?
       logger = ProTacts::DebugLogger.open_log(ProTacts.config.debug_log_path)
       use ProTacts::DebugLogger, logger: logger
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
index 2fdf4db..f2317ee 100644
--- a/test/pro_tacts/exchange_fixtures.rb
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -2,6 +2,8 @@
 require "pathname"
 require "rack/test"
 
+require "pro_tacts/tailscale_auth"
+
 # Loads and replays the recorded macOS Contacts exchange in
 # test/fixtures/macos-exchange. See that directory's README for provenance
 # and the request/response file format.
@@ -34,13 +36,19 @@ module ExchangeFixtures
       Response.new(status: status_line.to_i, headers: parse_headers(header_lines), body:)
     end
 
+    # Stands in for the Tailscale-User-Login the recorded session carried.
+    # It was stripped from the request files because it names a real tailnet
+    # user; the app now refuses requests without one, so the replay has to
+    # put an identity back.
+    REPLAY_LOGIN = "replay@example.com"
+
     # Rack env for the recorded headers: Content-Type and Depth are the only
     # ones the app reads, but replaying all of them keeps the fidelity.
     def env_for(step)
       step.headers.to_h { |name, value|
         key = name == "Content-Type" ? "CONTENT_TYPE" : "HTTP_#{name.tr('-', '_').upcase}"
         [key, value]
-      }
+      }.merge(ProTacts::TailscaleAuth::LOGIN_HEADER => REPLAY_LOGIN)
     end
 
     # Replays every recorded request against the app and rewrites the
diff --git a/test/pro_tacts/test_tailscale_auth.rb b/test/pro_tacts/test_tailscale_auth.rb
new file mode 100644
index 0000000..64b0242
--- /dev/null
+++ b/test/pro_tacts/test_tailscale_auth.rb
@@ -0,0 +1,85 @@
+require_relative "../test_helper"
+require "rack/test"
+
+require "pro_tacts/tailscale_auth"
+
+class TailscaleAuthTest < Minitest::Test
+  include Rack::Test::Methods
+
+  # Records what the middleware passed through, so a refused request can be
+  # distinguished from one the app merely ignored.
+  class Spy
+    attr_reader :env
+
+    def call(env)
+      @env = env
+      [200, { "Content-Type" => "text/plain" }, ["reached the app"]]
+    end
+  end
+
+  def setup
+    @spy = Spy.new
+    @app = ProTacts::TailscaleAuth.new(@spy)
+  end
+
+  attr_reader :app
+
+  def test_request_with_an_identity_reaches_the_app
+    header "Tailscale-User-Login", "alpha@example.com"
+
+    get "/"
+
+    assert_equal 200, last_response.status
+    assert_equal "reached the app", last_response.body
+  end
+
+  def test_identity_is_available_to_the_app
+    header "Tailscale-User-Login", "alpha@example.com"
+
+    get "/"
+
+    assert_equal "alpha@example.com", @spy.env[ProTacts::TailscaleAuth::IDENTITY]
+  end
+
+  def test_request_without_an_identity_is_refused
+    get "/"
+
+    assert_equal 403, last_response.status
+    assert_nil @spy.env
+  end
+
+  def test_request_with_an_empty_identity_is_refused
+    header "Tailscale-User-Login", ""
+
+    get "/"
+
+    assert_equal 403, last_response.status
+    assert_nil @spy.env
+  end
+
+  def test_request_with_a_blank_identity_is_refused
+    header "Tailscale-User-Login", "   "
+
+    get "/"
+
+    assert_equal 403, last_response.status
+    assert_nil @spy.env
+  end
+
+  def test_refusal_explains_itself_in_plain_text
+    get "/"
+
+    assert_equal "text/plain", last_response["Content-Type"]
+    assert_includes last_response.body, "Tailscale"
+  end
+
+  # Every route is gated, not just the address book: an unauthenticated
+  # request must not learn whether a path exists.
+  def test_refusal_covers_every_path
+    %w[/ /.well-known/carddav /dav/ /dav/principal/ /dav/addressbook/].each do |path|
+      get path
+
+      assert_equal 403, last_response.status, path
+    end
+  end
+end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index c8185ce..9a9afbe 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -15,6 +15,12 @@ class WebTest < Minitest::Test
     ProTacts::Web
   end
 
+  # Every request needs a Tailscale identity; the middleware refuses without
+  # one. Tests for that refusal are in TailscaleAuthTest.
+  def setup
+    header "Tailscale-User-Login", "test@example.com"
+  end
+
   def test_options_returns_dav_headers
     options "/dav/"