1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
require "pro_tacts"

module ProTacts
  # Who is asking: the login on Remote-User, which the proxy in front
  # of this app writes, and which is trustworthy only behind such a
  # proxy — reached directly the app trusts whatever it is handed, so
  # it must not be listening anywhere but localhost
  # (docs/plans/2026-09-15-identity-from-one-header.md, "Which
  # header").
  #
  # A request that names nobody raises rather than passing a nil
  # down (Web's error handler maps the raise to the 401). 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 login the proxy vouches for is accepted. Getting onto the tailnet
  # is the access control; the login only picks which address book a
  # client syncs (docs/plans/2026-09-12-per-user-books.md).
  #
  # The value arrives as written. Caddy copies a placeholder through
  # verbatim, and a tailnet login is an email address, so there is no
  # header encoding to undo — `tailscale serve` would MIME-encode a
  # non-ASCII value (it Q-encodes what it writes), but nothing in front
  # of this app is serve.
  module ProxyAuth
    # The header the proxy writes the login to, and Rack's spelling of
    # the same field in the environment. One pair, because three places
    # name it: the read below, the 401 that says which header was read
    # (Web's error handler), and the Sentry scrubber that keeps it out of
    # an event (config.ru).
    HEADER = "Remote-User" #: String
    ENV_KEY = "HTTP_#{HEADER.upcase.tr("-", "_")}" #: String

    # What #login raises for a request that names nobody. Its own
    # class, so Web's error handler can answer exactly it with the
    # 401 and nothing wider is ever caught — an unexpected failure
    # still propagates to Sentry (see web.rb).
    class MissingLogin < StandardError
    end

    # The login on a request. Read as UTF-8, which is the encoding a
    # login reaches the store in (AGENTS.md's note on STRICT
    # columns); bytes that are not text at all name nobody rather
    # than raising at the bind, several layers down, on a group
    # named after them.
    #: (Rack::env env) -> String
    def self.login(env)
      login = env[ENV_KEY].to_s.dup.force_encoding(Encoding::UTF_8)
      # Checked before strip, which raises on invalid bytes.
      raise MissingLogin, "the #{HEADER} header is not valid UTF-8" unless login.valid_encoding?

      login = login.strip
      raise MissingLogin, "no login on the #{HEADER} header" if login.empty?

      login
    end
  end
end