auth: read one login off one configurable header
A display name chose the book, and a name is free to move;
one header carries the login now, the thing a proxy can prove.
Remote-User unless a deployment names another to be read —
the digest hashes the login, so stale tokens get 410 instead.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/AGENTS.md b/AGENTS.md
index c73709b..5ed74fe 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,11 +93,12 @@ 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 `Tailscale-User-Login` and `Tailscale-User-Name`
- 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.
+- 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.
- 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 fb13d65..e86d64e 100644
--- a/README.md
+++ b/README.md
@@ -57,20 +57,24 @@ PUT stores the submitted card verbatim (RFC 6352 section 6.3.2), and the
change log the sync tokens count on is written with it, in one
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 401. That holds only
-while the app is reachable through serve alone — bind it to localhost.
+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
+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:
Funnel traffic, which is public, and traffic from tagged devices.
Each user syncs a book of their own rather than every card: the members
-of the group `sync:*`, which everyone gets, and of `sync:<name>`, where
-the name is the user's Tailscale display name in any case. A card a
-client creates joins `sync:*`, so everyone gets it until someone takes
-it out. See `docs/plans/2026-09-12-per-user-books.md` and
-`docs/plans/2026-09-15-client-creates-join-everyone.md`.
+of the group `sync:*`, which everyone gets, and of `sync:<login>`,
+matched in any case. A card a client creates joins `sync:*`, so everyone
+gets it until someone takes it out. See
+`docs/plans/2026-09-12-per-user-books.md`,
+`docs/plans/2026-09-15-client-creates-join-everyone.md`, and
+`docs/plans/2026-09-15-identity-from-one-header.md`.
DAV exchanges that go wrong — a status of 400 or more other than the 401
above, a crash, or a request that reported to Sentry — are written whole
diff --git a/Rakefile b/Rakefile
index aa19bed..24fa754 100644
--- a/Rakefile
+++ b/Rakefile
@@ -30,6 +30,12 @@ task :dev do
ENV["PRO_TACTS_EXCHANGE_LOG"] ||= "log/dev.log"
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"
+
Dir.mktmpdir("pro-tacts-dev") do |dir|
data_dir = Pathname.new(dir)
ENV["PRO_TACTS_DATA_DIR"] = data_dir.to_s
diff --git a/docs/plans/2026-09-15-identity-from-one-header.md b/docs/plans/2026-09-15-identity-from-one-header.md
new file mode 100644
index 0000000..1d43026
--- /dev/null
+++ b/docs/plans/2026-09-15-identity-from-one-header.md
@@ -0,0 +1,75 @@
+# Identity from one header
+
+2026-09-15. A request's identity is one login, read off one request
+header the deployment names. It replaces the pair of Tailscale headers
+and the display name that keyed a book.
+
+## What went wrong
+
+The deploy at `https://contacts/` sits behind Caddy with
+caddy-tailscale, not behind `tailscale serve`, and its site sent:
+
+```caddyfile
+header_up Tailscale-User-Login {http.auth.user.tailscale_login}
+header_up Tailscale-User-Name {http.auth.user.tailscale_user}
+```
+
+caddy-tailscale's README gives `tailscale_user` as "same as `user.id`",
+the email-ish user ID, and `tailscale_name` as the display name. So the
+header the book was keyed on carried a login. `Store#book` looked for a
+group named `sync:alpha@kejadlen.dev`, found none, and every user's book
+collapsed to the members of `sync:*` — one card of four, which reads
+from macOS Contacts as a sync that stopped working.
+
+The server never noticed. A name that matches no group is an ordinary
+empty book, and nothing in the app says whose book it just served.
+
+## One value
+
+Identity is now a single string, the requester's login, and there is no
+display name in the model. `ProxyAuth.login` reads it, `Web` holds it as
+`@login`, and it does the two jobs the old pair split: it keys
+`sync:<login>`, and it fills `CardDAVUsername` in the profile `/setup`
+serves.
+
+The module doing the reading is `ProxyAuth` now, not `TailscaleAuth`
+(the name `docs/plans/2026-09-12-per-user-books.md` still uses): the
+header it reads is a setting, and the proxy writing it is the
+deployment's to choose.
+
+A login is the identifier a display name never was. The three
+consequences `docs/plans/2026-09-12-per-user-books.md` accepted under
+"Whose name" — two users sharing a book, a rename moving a user off
+their group, a user typing someone else's name to take their book — are
+all gone with it. What it costs is group names: `sync:alpha@example.com`
+where `sync:Alpha Chen` read as English. A setting for that name is the
+next piece of work, not part of this one.
+
+## Which header
+
+`ProTacts.config.identity_header` names the header, and
+`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.
+
+The header is trustworthy for the same reason the pair was. `tailscale
+serve` strips `Tailscale-User-Login` from an incoming request before it
+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.
+
+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
+quiet fallback.
+
+## What a deployment has to change
+
+The sync token carries the first 16 hex digits of the SHA-256 of the
+login (`Web#book_digest`), so moving from a display name to a login
+changes every token. A client presenting an old one gets the 410 and
+`DAV:valid-sync-token` that `docs/plans/2026-09-12-per-user-books.md`
+specified for a renamed user, and resyncs from scratch. That is the
+designed path, and it is what the recorded sessions' fixtures moved for.
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index e37e755..9ed510a 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -61,6 +61,17 @@ 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/profile.rb b/lib/pro_tacts/profile.rb
index 85a0062..4b589b4 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -45,11 +45,11 @@ module ProTacts
end
# The server ignores the username and password: identity comes from
- # the Tailscale headers that serve injects (see
- # ProTacts::TailscaleAuth). /setup passes the requester's login
- # anyway, so the account says which tailnet user it is for. The
- # password stays a placeholder because the account form expects the
- # field, and dropping it is untested.
+ # the header the proxy in front of it writes (see
+ # ProTacts::ProxyAuth). /setup passes the requester's login anyway,
+ # so the account says which tailnet user it is for. The password
+ # stays a placeholder because the account form expects the field,
+ # and dropping it is untested.
#: (hostname: String, username: String) -> String
def self.render(hostname:, username:)
identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
diff --git a/lib/pro_tacts/tailscale_auth.rb b/lib/pro_tacts/proxy_auth.rb
similarity index 56%
rename from lib/pro_tacts/tailscale_auth.rb
rename to lib/pro_tacts/proxy_auth.rb
index 664535a..db23f22 100644
--- a/lib/pro_tacts/tailscale_auth.rb
+++ b/lib/pro_tacts/proxy_auth.rb
@@ -1,12 +1,17 @@
+require "pro_tacts"
+
module ProTacts
- # Who is asking, read off the identity headers Tailscale serve injects.
+ # 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).
#
- # 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, the
- # app trusts whatever it is handed. The app must not be listening
- # anywhere but localhost.
+ # `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.
#
# A request that names nobody is refused at the top of Web's route
# (Web#unauthorized). That covers the two cases Tailscale documents as
@@ -14,18 +19,10 @@ module ProTacts
# 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).
- module TailscaleAuth
- LOGIN_HEADER = "HTTP_TAILSCALE_USER_LOGIN"
- NAME_HEADER = "HTTP_TAILSCALE_USER_NAME"
-
- # Who is asking, both headers decoded. The signature is in
- # sig/pro_tacts/tailscale_auth.rbs.
- # @rbs skip
- Identity = Data.define(:login, :name)
-
+ # 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).
+ module ProxyAuth
# 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.
@@ -33,14 +30,20 @@ module ProTacts
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
- name = decode(env[NAME_HEADER].to_s)&.strip
- return if login.nil? || login.empty? || name.nil? || name.empty?
+ # 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
- Identity.new(login:, name:)
+ # 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("-", "_")}"
end
# A header value as serve writes it: Go's mime.QEncoding over the
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index d936e6a..d943c5d 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -172,7 +172,7 @@ module ProTacts
GROUP_COLUMNS = [Sequel[:groups][:id], Sequel[:groups][:name]].freeze #: Array[untyped]
# The group names that choose what a client syncs: `sync:*` for
- # everyone, `sync:<display name>` for one user
+ # everyone, `sync:<login>` for one user
# (docs/plans/2026-09-12-per-user-books.md). Unique like every
# group's name (db/migrations/008_group_names.rb).
SYNC_PREFIX = "sync:" #: String
@@ -357,13 +357,13 @@ module ProTacts
end
# The cards one user's client syncs: every member of `sync:*` and of
- # `sync:<name>`, each once (docs/plans/2026-09-12-per-user-books.md).
- # The name matches in any case, folded here in Ruby because SQLite's
+ # `sync:<login>`, each once (docs/plans/2026-09-12-per-user-books.md).
+ # The login matches in any case, folded here in Ruby because SQLite's
# lower() folds ASCII alone; the prefix only as #sync_name? reads it,
# since no other group's moves are logged.
- #: (String name) -> Set[String]
- def book(name)
- own = "#{SYNC_PREFIX}#{name}"
+ #: (String login) -> Set[String]
+ def book(login)
+ own = "#{SYNC_PREFIX}#{login}"
ids = groups.where(Sequel.function(:glob, "#{SYNC_PREFIX}*", :name)).all
.select { |group| (label = group.fetch(:name).to_s) == EVERYONE || label.casecmp?(own) }
.map { it.fetch(:id) }
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index a51dd5f..f96b50a 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -11,14 +11,14 @@ require "pro_tacts/contact"
require "pro_tacts/exchange_log"
require "pro_tacts/refusal_alerts"
require "pro_tacts/store"
-require "pro_tacts/tailscale_auth"
+require "pro_tacts/proxy_auth"
require "roda/plugins/dav_verbs"
module ProTacts
class Web < Roda
# @rbs @contacts: Array[Contact]?
# @rbs @ctag: String?
- # @rbs @identity: TailscaleAuth::Identity
+ # @rbs @login: String
# @rbs @book: Set[String]?
# The vendored Gloss CSS and the admin app's own stylesheet (see
@@ -79,8 +79,8 @@ module ProTacts
# what the trunk sets they see.
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)
+ # files included (ProTacts::ProxyAuth).
+ @login = ProxyAuth.login(r.env) || unauthorized(r)
r.public
r.hash_branches
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index 3a248bb..09e5ba4 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -322,7 +322,7 @@ module ProTacts
#: () -> Set[String]
def book
- @book ||= store.book(@identity.name)
+ @book ||= store.book(@login)
end
# One member of the requester's book, or nil for a card outside it
@@ -340,20 +340,20 @@ module ProTacts
# 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, and on the requester's name so
- # that a token is refused by any other book, the one a rename leaves
- # a user with included (docs/plans/2026-09-12-per-user-books.md, "The
- # wire").
+ # one sees changes at the same points, and on the requester's login
+ # so that a token is refused by any other book, the one a rename
+ # leaves a user with included (docs/plans/2026-09-12-per-user-books.md,
+ # "The wire").
#: () -> String
def sync_token
"http://pro-tacts/sync/#{ctag}/#{book_digest}"
end
# The name half of a sync token: the first 16 hex digits of the
- # SHA-256 of the requester's display name.
+ # SHA-256 of the requester's login.
#: () -> String
def book_digest
- Digest::SHA256.hexdigest(@identity.name)[0, 16].to_s
+ Digest::SHA256.hexdigest(@login)[0, 16].to_s
end
# The whole of the PUT route: a private method because a Roda route
diff --git a/lib/pro_tacts/web/setup.rb b/lib/pro_tacts/web/setup.rb
index 219f291..68615a0 100644
--- a/lib/pro_tacts/web/setup.rb
+++ b/lib/pro_tacts/web/setup.rb
@@ -35,7 +35,7 @@ module ProTacts
# is the point.
r.get "carddav.mobileconfig" do
response["Content-Type"] = "application/x-apple-aspen-config"
- Profile.render(hostname: r.host, username: @identity.login)
+ Profile.render(hostname: r.host, username: @login)
end
end
end
diff --git a/sig/pro_tacts/tailscale_auth.rbs b/sig/pro_tacts/tailscale_auth.rbs
deleted file mode 100644
index e3d6a2a..0000000
--- a/sig/pro_tacts/tailscale_auth.rbs
+++ /dev/null
@@ -1,13 +0,0 @@
-# Identity is a Data class, whose members the inline parser cannot read
-# (see sig/pro_tacts/contact.rbs). TailscaleAuth itself is an ordinary
-# module and its types are inline in tailscale_auth.rb.
-module ProTacts
- module TailscaleAuth
- class Identity
- attr_reader login: String
- attr_reader name: String
-
- def self.new: (login: String, name: String) -> instance
- end
- end
-end
diff --git a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
index 3dbff83..2185e73 100644
--- a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
+++ b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>19</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/19/336e00f5e1ec2674</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index 55254af..c7bc124 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>19</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/19/336e00f5e1ec2674</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 3dbff83..2185e73 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>19</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/19/336e00f5e1ec2674</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 55254af..c7bc124 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
</d:supported-report>
</d:supported-report-set>
<cs:getctag>19</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+ <d:sync-token>http://pro-tacts/sync/19/336e00f5e1ec2674</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 153da97..1bc5f57 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -21,8 +21,7 @@ class AdminContactsPagesTest < Minitest::Test
end
def setup
- header "Tailscale-User-Login", "test@example.com"
- header "Tailscale-User-Name", "Test User"
+ header "Remote-User", "test@example.com"
end
ADA = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
diff --git a/test/pro_tacts/admin/test_device_setup.rb b/test/pro_tacts/admin/test_device_setup.rb
index 3e5cef3..3719278 100644
--- a/test/pro_tacts/admin/test_device_setup.rb
+++ b/test/pro_tacts/admin/test_device_setup.rb
@@ -17,8 +17,7 @@ class AdminDeviceSetupTest < Minitest::Test
end
def setup
- header "Tailscale-User-Login", "test@example.com"
- header "Tailscale-User-Name", "Test User"
+ header "Remote-User", "test@example.com"
header "Host", "box.example.ts.net"
end
@@ -80,7 +79,7 @@ class AdminDeviceSetupTest < Minitest::Test
end
def test_document_names_the_requester_as_the_username
- header "Tailscale-User-Login", "zoe@example.com"
+ header "Remote-User", "zoe@example.com"
get "/setup/carddav.mobileconfig"
assert_match(%r{<key>CardDAVUsername</key>\s*<string>zoe@example.com</string>}, last_response.body)
diff --git a/test/pro_tacts/admin/test_footer.rb b/test/pro_tacts/admin/test_footer.rb
index 11bc16e..c4cc8ef 100644
--- a/test/pro_tacts/admin/test_footer.rb
+++ b/test/pro_tacts/admin/test_footer.rb
@@ -17,8 +17,7 @@ class AdminFooterTest < Minitest::Test
end
def setup
- header "Tailscale-User-Login", "test@example.com"
- header "Tailscale-User-Name", "Test User"
+ header "Remote-User", "test@example.com"
@config = ProTacts.config
end
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 0ac91f0..b27e94c 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -16,8 +16,7 @@ class AdminGroupsPagesTest < Minitest::Test
end
def setup
- header "Tailscale-User-Login", "test@example.com"
- header "Tailscale-User-Name", "Test User"
+ header "Remote-User", "test@example.com"
end
ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
index d66e4cf..e7cc78d 100644
--- a/test/pro_tacts/exchange_fixtures.rb
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -1,7 +1,7 @@
require "pathname"
require "rack/test"
-require "pro_tacts/tailscale_auth"
+require "pro_tacts/proxy_auth"
# One recorded client exchange: a directory of numbered steps, replayed
# in order. See each directory's README for provenance and the
@@ -28,12 +28,11 @@ class ExchangeFixtures < Data.define(:directory)
# (macos-exchange/README.md).
REQUEST_HEADERS = %w[Brief Content-Type Depth If-Match If-None-Match Prefer].freeze
- # Stand in for the Tailscale identity headers the recorded sessions
- # carried. They were stripped from the request files because they name
- # a real tailnet user; the app refuses requests without them, so the
- # replay has to put an identity back.
+ # Stands in for the identity header the recorded sessions carried. It
+ # was stripped from the request files because it names a real tailnet
+ # user; the app refuses requests without one, so the replay has to put
+ # a login back.
REPLAY_LOGIN = "replay@example.com"
- REPLAY_NAME = "Replay"
# Every recording, for the callers that run all of them.
def self.all
@@ -77,8 +76,7 @@ class ExchangeFixtures < Data.define(:directory)
key = name == "Content-Type" ? "CONTENT_TYPE" : "HTTP_#{name.tr('-', '_').upcase}"
[key, value]
}.merge(
- ProTacts::TailscaleAuth::LOGIN_HEADER => REPLAY_LOGIN,
- ProTacts::TailscaleAuth::NAME_HEADER => REPLAY_NAME,
+ ProTacts::ProxyAuth.env_key(ProTacts.config.identity_header) => REPLAY_LOGIN,
)
end
diff --git a/test/pro_tacts/test_config.rb b/test/pro_tacts/test_config.rb
index 8dc0a5a..3e41b87 100644
--- a/test/pro_tacts/test_config.rb
+++ b/test/pro_tacts/test_config.rb
@@ -58,6 +58,16 @@ 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-Login")
+
+ assert_equal "Tailscale-Login", 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
new file mode 100644
index 0000000..814476b
--- /dev/null
+++ b/test/pro_tacts/test_proxy_auth.rb
@@ -0,0 +1,78 @@
+require_relative "../test_helper"
+
+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)
+ env = {} #: Hash[String, String]
+ env[ProTacts::ProxyAuth.env_key(sent_as)] = value unless value.nil?
+ ProTacts::ProxyAuth.login(env, header: HEADER)
+ end
+
+ def test_the_header_is_the_login
+ assert_equal "alpha@example.com", login
+ end
+
+ def test_no_header_is_nobody
+ assert_nil login(nil)
+ end
+
+ def test_an_empty_login_is_nobody
+ assert_nil login("")
+ end
+
+ def test_a_blank_login_is_nobody
+ assert_nil login(" ")
+ end
+
+ def test_a_login_on_another_header_is_nobody
+ assert_nil login(sent_as: "Tailscale-Login")
+ end
+
+ def test_the_header_read_is_the_one_named
+ env = { ProTacts::ProxyAuth.env_key("Tailscale-Login") => "alpha@example.com" }
+
+ assert_equal "alpha@example.com", ProTacts::ProxyAuth.login(env, header: "Tailscale-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")
+ end
+
+ # What Go's mime.QEncoding writes for a non-ASCII value, which is how
+ # serve sends one.
+ def test_a_q_encoded_login_is_decoded
+ assert_equal "zoë@example.com", login("=?utf-8?q?zo=C3=AB@example.com?=")
+ end
+
+ def test_whitespace_between_encoded_words_is_dropped
+ assert_equal "Zoë Chen", login("=?utf-8?q?Zo=C3=AB?= =?utf-8?q?_Chen?=")
+ end
+
+ def test_whitespace_around_plain_text_is_kept
+ assert_equal "Dr Zoë Chen", login("Dr =?utf-8?q?Zo=C3=AB?= Chen")
+ end
+
+ def test_a_b_encoded_login_is_decoded
+ assert_equal "Zoë Chen", login("=?UTF-8?B?Wm/DqyBDaGVu?=")
+ end
+
+ def test_a_login_in_another_charset_is_nobody
+ assert_nil login("=?iso-8859-1?q?Zo=EB?=")
+ end
+
+ def test_a_login_that_decodes_to_invalid_utf8_is_nobody
+ assert_nil login("=?utf-8?q?Zo=EB?=")
+ end
+
+ def test_a_malformed_b_word_is_nobody
+ assert_nil login("=?utf-8?b?not*base64?=")
+ end
+end
diff --git a/test/pro_tacts/test_tailscale_auth.rb b/test/pro_tacts/test_tailscale_auth.rb
deleted file mode 100644
index 57a7fa6..0000000
--- a/test/pro_tacts/test_tailscale_auth.rb
+++ /dev/null
@@ -1,72 +0,0 @@
-require_relative "../test_helper"
-
-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
- 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_both_headers_are_the_identity
- assert_equal ProTacts::TailscaleAuth::Identity.new(login: "alpha@example.com", name: "Alpha Chen"), identity
- end
-
- def test_no_headers_are_nobody
- assert_nil identity(login: nil, name: nil)
- end
-
- def test_an_empty_login_is_nobody
- assert_nil identity(login: "")
- end
-
- def test_a_blank_login_is_nobody
- assert_nil identity(login: " ")
- end
-
- def test_a_missing_name_is_nobody
- assert_nil identity(name: nil)
- end
-
- 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
- assert_equal "Zoë Chen", identity(name: "=?utf-8?q?Zo=C3=AB_Chen?=").name
- end
-
- def test_a_q_encoded_login_is_decoded
- 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
- 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
- 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
- assert_equal "Zoë Chen", identity(name: "=?UTF-8?B?Wm/DqyBDaGVu?=").name
- end
-
- 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_nobody
- assert_nil identity(name: "=?utf-8?q?Zo=EB?=")
- 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_web.rb b/test/pro_tacts/test_web.rb
index 494b913..94aa899 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -21,13 +21,13 @@ class WebTest < Minitest::Test
ProTacts::Web
end
- # Every request needs a Tailscale identity; the route refuses without
- # one (see the refusal tests below). The sentry setup
+ # 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
# pins the transport the app's reports land in; teardown clears it.
def setup
setup_sentry
- header "Tailscale-User-Login", "test@example.com"
- header "Tailscale-User-Name", "Test User"
+ header "Remote-User", "test@example.com"
end
def teardown
@@ -165,8 +165,8 @@ class WebTest < Minitest::Test
## Refusing a request that names nobody
- def test_a_request_without_a_name_is_refused
- header "Tailscale-User-Name", ""
+ def test_a_request_without_a_login_is_refused
+ header "Remote-User", ""
get "/dav/principal/"
@@ -175,7 +175,7 @@ class WebTest < Minitest::Test
# RFC 9110 section 15.5.2: a 401 carries a challenge.
def test_a_refusal_names_tailscale_as_the_credential
- header "Tailscale-User-Login", ""
+ header "Remote-User", ""
get "/"
@@ -187,7 +187,7 @@ class WebTest < Minitest::Test
# 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", ""
+ header "Remote-User", ""
%w[/ /.well-known/carddav /dav/ /dav/principal/ /dav/addressbook/ /contacts /groups /setup /admin.css].each do |path|
get path
@@ -785,8 +785,8 @@ class WebTest < Minitest::Test
end
# A sync token as the route issues one to a user (Web#sync_token).
- def issued_token(sequence, name: "Test User")
- "http://pro-tacts/sync/#{sequence}/#{Digest::SHA256.hexdigest(name)[0, 16]}"
+ def issued_token(sequence, login: "test@example.com")
+ "http://pro-tacts/sync/#{sequence}/#{Digest::SHA256.hexdigest(login)[0, 16]}"
end
def etag_only_propfind
@@ -953,12 +953,12 @@ class WebTest < Minitest::Test
end
end
- # A token names the book it was issued for, so a user whose name
+ # A token names the book it was issued for, so a user whose login
# changed resyncs rather than taking a delta against another book —
# and a token from before tokens named one is refused the same way.
def test_sync_collection_refuses_a_token_from_another_book
with_contacts({"aiden" => "Aiden"}) do
- request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(1, name: "Zoë Chen"))
+ request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(1, login: "zoë@example.com"))
assert_equal 410, last_response.status
request "/dav/addressbook/", method: "REPORT", input: sync_collection("http://pro-tacts/sync/1")
@@ -1000,12 +1000,12 @@ class WebTest < Minitest::Test
def test_each_user_syncs_their_own_book
with_contacts({"aiden" => "Aiden"}) do |store|
store.put("znorth", ProTacts::VCard.new(card("znorth", "Zed")))
- FixtureData.seed_group(store, name: "sync:Zoë Chen", members: ["znorth"])
+ FixtureData.seed_group(store, name: "sync:zoë@example.com", members: ["znorth"])
request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
refute_includes last_response.body, "znorth"
- header "Tailscale-User-Name", "=?utf-8?q?Zo=C3=AB_Chen?="
+ header "Remote-User", "=?utf-8?q?zo=C3=AB@example.com?="
request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
assert_includes last_response.body, "aiden.vcf"
assert_includes last_response.body, "znorth.vcf"
@@ -1019,8 +1019,8 @@ class WebTest < Minitest::Test
put_request "new", card("new", "New"), "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
assert_equal 201, last_response.status
- assert_equal Set["new"], store.book("Test User")
- assert_equal Set["new"], store.book("Zoë Chen")
+ assert_equal Set["new"], store.book("test@example.com")
+ assert_equal Set["new"], store.book("zoë@example.com")
end
end
diff --git a/test/tasks/test_fixtures.rb b/test/tasks/test_fixtures.rb
index edb11c9..e7992d3 100644
--- a/test/tasks/test_fixtures.rb
+++ b/test/tasks/test_fixtures.rb
@@ -29,7 +29,7 @@ class FixturesTasksTest < Minitest::Test
def test_the_request_is_written_as_a_step_without_what_identifies_the_tailnet
put "/dav/addressbook/new.vcf", CARD,
"CONTENT_TYPE" => "text/vcard", "HTTP_IF_MATCH" => '"abc"', "HTTP_USER_AGENT" => "AddressBookCore",
- "HTTP_TAILSCALE_USER_LOGIN" => "ada@example.com", "HTTP_TAILSCALE_USER_NAME" => "Ada"
+ "HTTP_REMOTE_USER" => "ada@example.com"
extract(exchange, @root / "07-put")