auth: refuse a request with no identity from the route
The route refuses a request that names nobody, 401 with a Tailscale challenge;
the unhandled capture skips it, and only the debug log keeps that exchange.

Assisted-by: Claude Opus 5 via Claude Code
change qwynstwozwwqmruxwsluuzkkonwnnwom
commit 77310b337c5b5404567d9daa972b1e823b4284d6
author Alpha Chen <alpha@kejadlen.dev>
date
parent nuvlszpm
diff --git a/AGENTS.md b/AGENTS.md
index 50352d0..e35a3da 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -81,7 +81,7 @@ not a fixture; edit a `.vcf` to change what the replay serves.
   inside one is sent to the client — keep notes about the code in Ruby
   comments outside the heredoc.
 - Every request needs `Tailscale-User-Login` and `Tailscale-User-Name`
-  headers or it gets a 403, so a bare `curl` against `rake dev` is
+  headers or it gets a 401, so a bare `curl` against `rake dev` is
   refused until you pass both. The
   security of that rests on the app being reachable only through
   `tailscale serve` — never bind it to anything but localhost.
diff --git a/README.md b/README.md
index cab5438..f9081e9 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ transaction.
 Requests are authenticated by the `Tailscale-User-Login` and
 `Tailscale-User-Name` headers that `tailscale serve` injects, which it
 strips from incoming requests so a client cannot forge them. A request
-without both gets a 403. That holds only
+without both gets a 401. 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.
diff --git a/lib/pro_tacts/tailscale_auth.rb b/lib/pro_tacts/tailscale_auth.rb
index 5150ff4..664535a 100644
--- a/lib/pro_tacts/tailscale_auth.rb
+++ b/lib/pro_tacts/tailscale_auth.rb
@@ -1,23 +1,23 @@
 module ProTacts
-  # Gates every request on the identity headers Tailscale serve injects.
+  # Who is asking, read off the identity headers Tailscale serve injects.
   #
   # Serve sets Tailscale-User-Login and Tailscale-User-Name from the
   # tailnet identity of the calling node, and strips both from incoming
   # requests before proxying so a client cannot supply its own. That makes
-  # the headers trustworthy, but only behind serve: reached directly, this
-  # middleware trusts whatever it is handed. The app must not be listening
+  # the headers trustworthy, but only behind serve: reached directly, the
+  # app 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.
+  # A request that names nobody is refused at the top of Web's route
+  # (Web#unauthorized). That 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
+  # 401s.
   #
   # Any tailnet identity is accepted. Getting onto the tailnet is the access
   # control; the name only picks which address book a client syncs
   # (docs/plans/2026-09-12-per-user-books.md).
-  class TailscaleAuth
-    # @rbs @app: Rack::_App
-
+  module TailscaleAuth
     LOGIN_HEADER = "HTTP_TAILSCALE_USER_LOGIN"
     NAME_HEADER = "HTTP_TAILSCALE_USER_NAME"
 
@@ -26,9 +26,14 @@ module ProTacts
     # @rbs skip
     Identity = Data.define(:login, :name)
 
-    # The identity on a request, or nil for one that names nobody. The
-    # gate below asks it to refuse a request, and the app asks it again
-    # to learn who got through.
+    # One RFC 2047 encoded-word (section 2), and the whitespace after it
+    # when another follows: section 6.2 has that whitespace dropped, so a
+    # long value split across words decodes whole.
+    ENCODED_WORD = /=\?([^?\s]+)\?([QqBb])\?([^?\s]+)\?=/ #: Regexp
+    ENCODED_RUN = /#{ENCODED_WORD}(?:[ \t]+(?=#{ENCODED_WORD}))?/ #: Regexp
+    private_constant :ENCODED_WORD, :ENCODED_RUN
+
+    # The identity on a request, or nil for one that names nobody.
     #: (Rack::env env) -> Identity?
     def self.identity(env)
       login = decode(env[LOGIN_HEADER].to_s)&.strip
@@ -38,13 +43,6 @@ module ProTacts
       Identity.new(login:, name:)
     end
 
-    # One RFC 2047 encoded-word (section 2), and the whitespace after it
-    # when another follows: section 6.2 has that whitespace dropped, so a
-    # long value split across words decodes whole.
-    ENCODED_WORD = /=\?([^?\s]+)\?([QqBb])\?([^?\s]+)\?=/ #: Regexp
-    ENCODED_RUN = /#{ENCODED_WORD}(?:[ \t]+(?=#{ENCODED_WORD}))?/ #: Regexp
-    private_constant :ENCODED_WORD, :ENCODED_RUN
-
     # A header value as serve writes it: Go's mime.QEncoding over the
     # UTF-8 value (encTailscaleHeaderValue in tailscale's
     # ipn/ipnlocal/serve.go), which leaves an ASCII value as it is. Nil
@@ -78,31 +76,5 @@ module ProTacts
       nil
     end
     private_class_method :decode_word
-
-    #: (Rack::_App app) -> void
-    def initialize(app)
-      @app = app
-    end
-
-    #: (Rack::env env) -> Rack::response
-    def call(env)
-      # 403 rather than 401: no credentials the client could supply would
-      # help, so there is no challenge worth sending.
-      return forbidden if self.class.identity(env).nil?
-
-      @app.call(env)
-    end
-
-    private
-
-    #: () -> Rack::response
-    def forbidden
-      body = "Forbidden: no Tailscale identity on this request.\n"
-      # Lowercase names: a bare Rack 3 response hash is not normalized
-      # on the way out the way Roda's response[ ]= is, and rackup's
-      # development Lint rejects an uppercase name with a 500 that hides
-      # the 403.
-      [403, { "content-type" => "text/plain", "content-length" => body.bytesize.to_s }, [body]]
-    end
   end
 end
diff --git a/lib/pro_tacts/unhandled_requests.rb b/lib/pro_tacts/unhandled_requests.rb
index 1dfd22b..91f0d84 100644
--- a/lib/pro_tacts/unhandled_requests.rb
+++ b/lib/pro_tacts/unhandled_requests.rb
@@ -20,10 +20,10 @@ module ProTacts
     # 404 is the missing-functionality signal: a client asked for something
     # this server does not route. 5xx is kept because Sentry now reports
     # those without a body, and a crash is hard to read without one. 403 is
-    # the routed-but-unimplemented case, today an unsupported REPORT type —
-    # safe to capture because TailscaleAuth sits above this middleware and
-    # refuses unauthenticated requests before they reach it, so every 403
-    # arriving here came from the app.
+    # the routed-but-unimplemented case, today an unsupported REPORT type.
+    # An unauthenticated request is refused with a 401 (Web#unauthorized),
+    # which is not kept, so no capture holds a body from outside the
+    # tailnet.
     #: (Integer status) -> bool
     def self.capture?(status)
       status == 403 || status == 404 || status >= 500
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index ef506ef..684d7fb 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -29,6 +29,7 @@ module ProTacts
   class Web < Roda
     # @rbs @contacts: Array[Contact]?
     # @rbs @ctag: String?
+    # @rbs @identity: TailscaleAuth::Identity
 
     # The vendored Gloss CSS and the admin app's own stylesheet (see
     # docs/DESIGN.md); relative to this file rather than $0 for the same
@@ -76,14 +77,16 @@ module ProTacts
     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
-
-    # Below the auth gate: a refused request is not missing functionality,
-    # and recording one would write an unauthenticated body to disk.
+    # Outside the route's identity gate (#unauthorized), and safe there
+    # because a refusal is a 401, which it does not keep
+    # (UnhandledRequests.capture?): a refused request is not missing
+    # functionality, and recording one would write an unauthenticated
+    # body to disk.
     use ProTacts::UnhandledRequests, directory: ProTacts.config.unhandled_dir
 
+    # Outside the identity gate too, so a refused request is dumped with
+    # the rest. That is the point of a debug log, which is off by default
+    # and kept on a local machine.
     if ProTacts.config.debug?
       logger = ProTacts::DebugLogger.open_log(ProTacts.config.debug_log_path)
       use ProTacts::DebugLogger, logger: logger
@@ -103,6 +106,10 @@ module ProTacts
     # and service discovery (RFC 6764). Each handler cites its section, and
     # the texts are vendored under docs/rfcs to check them against.
     route do |r|
+      # Every request names a tailnet user or is refused, the static
+      # files included (ProTacts::TailscaleAuth).
+      @identity = TailscaleAuth.identity(r.env) || unauthorized(r)
+
       r.public
 
       # The card browser, one segment down from the page at the root:
@@ -635,6 +642,19 @@ module ProTacts
       )
     end
 
+    # 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.
+    #: (Roda::RodaRequest r) -> bot
+    def unauthorized(r)
+      response.status = 401
+      response["WWW-Authenticate"] = "Tailscale"
+      response["Content-Type"] = "text/plain"
+      response.write("Unauthorized: no Tailscale identity on this request.\n")
+      r.halt
+    end
+
     # Sync tokens are opaque to the client (RFC 6578 section 3); the URI
     # form is conventional. Built on the ctag so a client polling either
     # one sees changes at the same points.
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index 0d28283..136ee66 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -1,6 +1,6 @@
 # Roda ships no signatures. This is the routing surface web.rb uses and
 # no more of it: the class methods that build the app, the request
-# methods that match, and the two response setters.
+# methods that match and halt, and the response setters and writer.
 #
 # Matchers stay untyped on purpose. Roda's matcher vocabulary — strings,
 # classes, regexps, symbols, hashes, and arrays of those — is open, and
@@ -54,11 +54,15 @@ class Roda
 
     # Halts the request, so nothing after it in a route block runs.
     def redirect: (String path, ?Integer status) -> bot
+
+    # Ends the request with the response as it stands.
+    def halt: () -> bot
   end
 
   class RodaResponse
     def []=: (String header, String value) -> void
     def status=: (Integer status) -> void
+    def write: (String body) -> void
   end
 
   module RodaPlugins
diff --git a/sig/pro_tacts/tailscale_auth.rbs b/sig/pro_tacts/tailscale_auth.rbs
index 843e353..e3d6a2a 100644
--- a/sig/pro_tacts/tailscale_auth.rbs
+++ b/sig/pro_tacts/tailscale_auth.rbs
@@ -1,8 +1,8 @@
 # Identity is a Data class, whose members the inline parser cannot read
 # (see sig/pro_tacts/contact.rbs). TailscaleAuth itself is an ordinary
-# class and its types are inline in tailscale_auth.rb.
+# module and its types are inline in tailscale_auth.rb.
 module ProTacts
-  class TailscaleAuth
+  module TailscaleAuth
     class Identity
       attr_reader login: String
       attr_reader name: String
diff --git a/test/pro_tacts/test_tailscale_auth.rb b/test/pro_tacts/test_tailscale_auth.rb
index ec3fcce..57a7fa6 100644
--- a/test/pro_tacts/test_tailscale_auth.rb
+++ b/test/pro_tacts/test_tailscale_auth.rb
@@ -1,181 +1,72 @@
 require_relative "../test_helper"
-require "rack/test"
 
 require "pro_tacts/tailscale_auth"
 
+# What the headers say. That a request naming nobody is refused, and how,
+# is Web's (see WebTest).
 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 identify(login: "alpha@example.com", name: "Alpha Chen")
-    header "Tailscale-User-Login", login
-    header "Tailscale-User-Name", name
-  end
-
-  def identity
-    ProTacts::TailscaleAuth.identity(@spy.env)
+  def identity(login: "alpha@example.com", name: "Alpha Chen")
+    env = {} #: Hash[String, String]
+    env[ProTacts::TailscaleAuth::LOGIN_HEADER] = login unless login.nil?
+    env[ProTacts::TailscaleAuth::NAME_HEADER] = name unless name.nil?
+    ProTacts::TailscaleAuth.identity(env)
   end
 
-  def test_request_with_an_identity_reaches_the_app
-    identify
-
-    get "/"
-
-    assert_equal 200, last_response.status
-    assert_equal "reached the app", last_response.body
-  end
-
-  def test_the_app_can_read_who_got_through
-    identify
-
-    get "/"
-
+  def test_both_headers_are_the_identity
     assert_equal ProTacts::TailscaleAuth::Identity.new(login: "alpha@example.com", name: "Alpha Chen"), identity
   end
 
-  def test_request_without_an_identity_is_refused
-    get "/"
-
-    assert_equal 403, last_response.status
-    assert_nil @spy.env
+  def test_no_headers_are_nobody
+    assert_nil identity(login: nil, name: nil)
   end
 
-  def test_request_with_an_empty_login_is_refused
-    identify(login: "")
-
-    get "/"
-
-    assert_equal 403, last_response.status
-    assert_nil @spy.env
+  def test_an_empty_login_is_nobody
+    assert_nil identity(login: "")
   end
 
-  def test_request_with_a_blank_login_is_refused
-    identify(login: "   ")
-
-    get "/"
-
-    assert_equal 403, last_response.status
-    assert_nil @spy.env
+  def test_a_blank_login_is_nobody
+    assert_nil identity(login: "   ")
   end
 
-  def test_request_without_a_name_is_refused
-    header "Tailscale-User-Login", "alpha@example.com"
-
-    get "/"
-
-    assert_equal 403, last_response.status
-    assert_nil @spy.env
+  def test_a_missing_name_is_nobody
+    assert_nil identity(name: nil)
   end
 
-  def test_request_with_a_blank_name_is_refused
-    identify(name: "   ")
-
-    get "/"
-
-    assert_equal 403, last_response.status
-    assert_nil @spy.env
+  def test_a_blank_name_is_nobody
+    assert_nil identity(name: "   ")
   end
 
   # What Go's mime.QEncoding writes for a non-ASCII value, which is how
   # serve sends one.
   def test_a_q_encoded_name_is_decoded
-    identify(name: "=?utf-8?q?Zo=C3=AB_Chen?=")
-
-    get "/"
-
-    assert_equal "Zoë Chen", identity.name
+    assert_equal "Zoë Chen", identity(name: "=?utf-8?q?Zo=C3=AB_Chen?=").name
   end
 
   def test_a_q_encoded_login_is_decoded
-    identify(login: "=?utf-8?q?zo=C3=AB@example.com?=")
-
-    get "/"
-
-    assert_equal "zoë@example.com", identity.login
+    assert_equal "zoë@example.com", identity(login: "=?utf-8?q?zo=C3=AB@example.com?=").login
   end
 
   def test_whitespace_between_encoded_words_is_dropped
-    identify(name: "=?utf-8?q?Zo=C3=AB?= =?utf-8?q?_Chen?=")
-
-    get "/"
-
-    assert_equal "Zoë Chen", identity.name
+    assert_equal "Zoë Chen", identity(name: "=?utf-8?q?Zo=C3=AB?= =?utf-8?q?_Chen?=").name
   end
 
   def test_whitespace_around_plain_text_is_kept
-    identify(name: "Dr =?utf-8?q?Zo=C3=AB?= Chen")
-
-    get "/"
-
-    assert_equal "Dr Zoë Chen", identity.name
+    assert_equal "Dr Zoë Chen", identity(name: "Dr =?utf-8?q?Zo=C3=AB?= Chen").name
   end
 
   def test_a_b_encoded_name_is_decoded
-    identify(name: "=?UTF-8?B?Wm/DqyBDaGVu?=")
-
-    get "/"
-
-    assert_equal "Zoë Chen", identity.name
+    assert_equal "Zoë Chen", identity(name: "=?UTF-8?B?Wm/DqyBDaGVu?=").name
   end
 
-  def test_a_name_in_another_charset_is_refused
-    identify(name: "=?iso-8859-1?q?Zo=EB?=")
-
-    get "/"
-
-    assert_equal 403, last_response.status
-    assert_nil @spy.env
+  def test_a_name_in_another_charset_is_nobody
+    assert_nil identity(name: "=?iso-8859-1?q?Zo=EB?=")
   end
 
-  def test_a_name_that_decodes_to_invalid_utf8_is_refused
-    identify(name: "=?utf-8?q?Zo=EB?=")
-
-    get "/"
-
-    assert_equal 403, last_response.status
-    assert_nil @spy.env
+  def test_a_name_that_decodes_to_invalid_utf8_is_nobody
+    assert_nil identity(name: "=?utf-8?q?Zo=EB?=")
   end
 
-  def test_a_malformed_b_word_is_refused
-    identify(name: "=?utf-8?b?not*base64?=")
-
-    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
+  def test_a_malformed_b_word_is_nobody
+    assert_nil identity(name: "=?utf-8?b?not*base64?=")
   end
 end
diff --git a/test/pro_tacts/test_unhandled_requests.rb b/test/pro_tacts/test_unhandled_requests.rb
index 03175ea..687f4ef 100644
--- a/test/pro_tacts/test_unhandled_requests.rb
+++ b/test/pro_tacts/test_unhandled_requests.rb
@@ -61,9 +61,8 @@ class UnhandledRequestsTest < Minitest::Test
     assert_equal 1, captures.size
   end
 
-  # An unsupported REPORT type. TailscaleAuth refuses unauthenticated
-  # requests above this middleware, so a 403 reaching it is always the app
-  # saying it routed the request and will not serve it.
+  # An unsupported REPORT type: the app saying it routed the request and
+  # will not serve it. An unauthenticated request is a 401 instead.
   def test_a_403_from_the_app_is_captured
     @stub.status = 403
 
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 2542c27..7605e4b 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -21,8 +21,8 @@ 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. The sentry setup
+  # Every request needs a Tailscale identity; the route refuses without
+  # one (see the refusal tests below). The sentry setup
   # pins the transport the app's reports land in; teardown clears it.
   def setup
     setup_sentry
@@ -121,8 +121,8 @@ class WebTest < Minitest::Test
     assert_includes last_response.body, "AB12C345-6789-0DEF-1234-567890ABCDEF.vcf"
   end
 
-  # Guards the wiring rather than the middleware: mounted in the stack, below
-  # the auth gate, pointed at the configured directory.
+  # Guards the wiring rather than the middleware: mounted in the stack and
+  # pointed at the configured directory.
   def test_an_unhandled_request_is_kept_on_disk
     directory = ProTacts.config.unhandled_dir
     FileUtils.rm_rf(directory)
@@ -146,12 +146,45 @@ class WebTest < Minitest::Test
     header "Tailscale-User-Login", ""
     get "/dav/addressbook/no-such-contact.vcf"
 
-    assert_equal 403, last_response.status
+    assert_equal 401, last_response.status
     refute Pathname.new(directory).exist?, "a refused request should leave nothing behind"
   ensure
     FileUtils.rm_rf(directory)
   end
 
+  ## Refusing a request that names nobody
+
+  def test_a_request_without_a_name_is_refused
+    header "Tailscale-User-Name", ""
+
+    get "/dav/principal/"
+
+    assert_equal 401, last_response.status
+  end
+
+  # RFC 9110 section 15.5.2: a 401 carries a challenge.
+  def test_a_refusal_names_tailscale_as_the_credential
+    header "Tailscale-User-Login", ""
+
+    get "/"
+
+    assert_equal "Tailscale", last_response["WWW-Authenticate"]
+    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_a_refusal_covers_every_path
+    header "Tailscale-User-Login", ""
+
+    %w[/ /.well-known/carddav /dav/ /dav/principal/ /dav/addressbook/ /contacts /groups /setup /admin.css].each do |path|
+      get path
+
+      assert_equal 401, last_response.status, path
+    end
+  end
+
   def test_get_unknown_contact_is_404
     get "/dav/addressbook/nope.vcf"