M1-43: Trusted proxy header login¶
Status: ready | Depends on: M1-21, M1-37 | Milestone: M1
Goal¶
After this ticket a person behind a reverse proxy that sets an identity header (for example
tailscale serve) logs in with that header instead of a login flow: the driver trusted_header
evaluates the header only while the public listener is bound to loopback, only from trusted peers,
only for the login itself, and, with auth.trusted_header_bind_requests, on every later request of
the session. Device keys and DPoP-bound tokens stay the same as for every other driver. A
misconfiguration fails closed: the driver stays off and readiness reports it (W4).
Read first¶
docs/spec/08-identity.md: 2.1 "Port", 2.4 "Trusted proxy header", 3.1 "Mapping an external identity", 3.2 "Enrollment code", 5.1, 5.2, 7.3 "DPoP", 13 "Login protection", 15 (ID-T08, ID-T09, ID-T10, ID-T25), 16 (R-ID-03), 18 (auth.trusted_header_bind_requests).docs/spec/16-operations.md: 4.2 "Two listeners", 4.3 "Edge and identity", 14 (server.listen_addr,server.trusted_proxies), R-OP-02.docs/spec/19-security.md: 2 "Transport" (SEC-TLS-1), 9 (SEC-LOG-2: identity headers never logged), 14 SEC-T01.docs/spec/02-architecture.md: 9 "Request path" (step 4), 13 "Configuration loading" point 4, 15.2 "Health".docs/spec/03-data-model.md: 4.2identity_providers,user_identities,auth_sessions(auth_method,revoke_reason),enrollment_codes,devices.docs/spec/05-actions.mdanddocs/spec/actions.yaml:auth.login.complete,identity_provider.manage, 2.5.docs/spec/06-api.md: 3.2 "Login flows" point 2, 5 "Errors" point 5 (public endpoints answer404when switched off).docs/spec/07-permissions.md: 4.1 (session_revoked), SR7.docs/PLAN.mdsection 7 "Known contradictions that M1 resolves" W4, rows M1-11, M1-21, M1-34, M1-36, M1-37, M1-43, M1-64; section 3.docs/operations/tailscale.mdsections 2 to 4.- ADRs 0019, 0020.
docs/api/openapi.yaml: operationloginWithTrustedHeader; schemasTrustedHeaderLoginRequest,DeviceRegistration,TokenResponse; operationscreateIdentityProvider,updateIdentityProvider.docs/security/threat-model.md: T-ID-02, T-NW-02.- Merged READMEs and code:
internal/identity(authenticator of M1-34, provider registry of M1-36, enrollment of M1-37, the provisioning handle helper of M1-41 if merged),internal/platform/httpserver(client IP and trusted proxies of M1-18 and M1-21),internal/platform/health(M1-11),services/server/api/operations.yaml. docs/quality/gates.md,CONTRIBUTING.md.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behavior change has a test that was red before the change (the red run is quoted in the report), then green, then refactor.
- No en dash or em dash characters in any file you write; use a hyphen or rephrase.
- White label: logins in tests are
alex@example.com,sam@example.com; no real tailnet, host or IP except127.0.0.1,::1and the documentation ranges of RFC 5737 and RFC 3849. - Limits and switches only through named configuration keys (
server.trusted_proxies,auth.trusted_header_bind_requests). - The header is evaluated only while the public listener is bound to loopback (08 2.4 rule 1 as
resolved by W4: driver stays off,
/readyzreportstrusted_header_requires_loopback,identity_provider.managerefuses to enable it). - The header is evaluated only when the TCP peer is in
server.trusted_proxies(08 2.4 rule 2, SEC-TLS-1); a missing login header is anonymous and the login answers401(rule 3, ID-T09). - The header replaces only the login, not device keys and tokens (rule 4).
- With
auth.trusted_header_bind_requests = trueevery later request of the session carries the same login; a mismatch gives401, revokes the session and is audited (rule 5, ID-T10). - RFC 2047 encoded values are decoded before the comparison; the login is compared, not the display name (rule 6).
- Matching only by
(provider_id, subject)or an enrollment code; provisioning only withallow_provisioning(08 3.1). - Identity headers never appear in logs, traces, audit details or error bodies (SEC-LOG-2).
Scope¶
services/server/internal/identity/domain/trustedheader/: pure functions: header value decoding and validation, loopback decision for a bound address, the config typeidentity_provider.trusted_header.v1and its validation.services/server/internal/identity/app/: use caseLoginWithTrustedHeader; driver state (active or off with reason); the per-request binding check called by the authenticator.services/server/internal/identity/adapters/httpapi/: handlerloginWithTrustedHeader; the handler passes the raw TCP peer address (never a forwarded address) and the configured header values to the use case.- The
kernel/actor.Authenticatorimplementation of M1-34 ininternal/identity: binding check for sessions withauth_method = 'trusted_header'(Design notes). identity_provider.managepath of M1-36: config validator fortrusted_headerand the refusal to enable the driver without loopback.internal/platform/healthregistration (throughinternal/wire) of the readiness checktrusted_header_requires_loopback.internal/wire: computes the loopback flag from the bound public listener address and passes it into theidentityconfig.- If
internal/platform/configcontains a start-time cross-check "header driver only with a loopback listener" (02 13 point 4), remove it in favor of the readiness check and adapt its test; no other change inplatform/config. - The specification is in place (planning, spec first; W4 resolved there): 08 2.4 (rules 1 to 7
with W4, binding, RFC 2047), 2.6 (
identity_provider.trusted_header.v1), 3.1 (enrollment code at the header login), ID-T08 to ID-T10; 16 4.3; 19 SEC-T01; 02 13 point 4; 03auth_sessions.revoke_reasonwithtrusted_header_mismatch;openapi.yaml(TrustedHeaderLoginRequest.enrollment_code). Change them only where the implementation proves them wrong (if the descriptions of404and409on the provider operations lack the loopback rule, add it, spec first).services/server/api/operations.yaml:loginWithTrustedHeadermoves to the built list; regenerated server code. docs/operations/tailscale.mdsection 4 with a complete provider config example;services/server/internal/identity/README.md; CHANGELOG entry under "Unreleased" ofservices/server/CHANGELOG.md;docs/DECISIONS.mdentry for W4 and for the rulings below;docs/security/threat-model.mdT-ID-02 test column with the Go test names.
Out of scope¶
- The identity query over the local API of the network agent (Tailscale
WhoIs): a later driver (08 2.4). - TLS and plaintext acceptance of listeners: M1-21 (reused here).
- Binding check on the gateway upgrade: the authenticator is shared, but the gateway endpoint is M1-64; note it in the report as a test M1-64 must carry.
- Provider CRUD itself: M1-36. Enrollment code issuance: M1-37. Device approval: M1-38.
- The Compose profile
tailscale: M7 portability.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-43-identity-trusted-header, created from the base branch the launcher names. - Conventional Commits with the module as scope (
feat(identity): ...) and a DCO sign-off on every commit (git commit -s). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the Scope list, except the docs and changelog entries named in this brief.
- Follow the host rules supplied by the launcher.
- Tests bind only
127.0.0.1and port 0 (P-10); never bind0.0.0.0or::(non-loopback cases are tested with constructed addresses). PostgreSQL only throughinternal/platform/pg/pgtest; without a Docker-compatible runtime the test fails, it never skips (P-09). - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets.
- No network calls except package downloads for pinned tools; never install or call a real proxy or tailnet.
- A gap in the core contract (00, 03, 04, 05,
docs/spec/actions.yaml) ends the ticket withVerdict: BLOCKED(P-11); never build a deviation.
Design notes¶
Preconditions (check before writing code; any failure: Verdict: BLOCKED <which>).
docs/spec/03-data-model.mdauth_sessions.revoke_reasoncontainstrusted_header_mismatch(also inSessionRevokedPayload.reason); ID-T10 requires a revocation with a stated reason.- The authenticator and the session revocation of M1-34 exist (revocation also revokes the family
and writes
session.revoked), and M1-37 provides enrollment-code verification and consumption plus the shared login completion (device registration, session, token pair).
Provider configuration identity_provider.trusted_header.v1 (strict decoding, unknown fields
400 validation_failed; normative in 08 2.6):
| Field | Type | Required | Meaning |
|---|---|---|---|
login_header |
HTTP header name (RFC 9110 token) | yes | carries the login, e.g. Tailscale-User-Login |
name_header |
HTTP header name | no | display name at provisioning, e.g. Tailscale-User-Name |
Only one trusted_header provider exists (unique index, M1-36).
Loopback decision. internal/wire reads the actual bound address of the public listener
(server.listen_addr after listen) and computes PublicListenerLoopback = ip.IsLoopback() (true
for 127.0.0.0/8 and ::1; false for the unspecified addresses 0.0.0.0 and :: and every
other address). Reason: the configured string can be a host name; the bound socket is the fact.
Driver state. Active exactly when an enabled trusted_header provider exists and
PublicListenerLoopback is true. Otherwise:
loginWithTrustedHeaderanswers404 not_found(06 5 point 5: public endpoints do not reveal configuration).- The readiness check
trusted_header_requires_loopbackfails (503 on/readyzwith only this check name) while an enabledtrusted_headerprovider exists and the listener is not loopback. createIdentityProviderwithkind = trusted_headerandenabledtrue (the default), andupdateIdentityProvidersettingenabledtrue, answer409 conflictwithreason: trusted_header_requires_loopbackand ahintnamingserver.listen_addr; audit deny.
Peer and header evaluation (one function used by login and binding):
- The peer is the IP of the TCP connection (
RemoteAddr), never a forwarded value. If it is not inserver.trusted_proxies, the identity headers are treated as absent. - Exactly one value of
login_header; several values are treated as absent and logged as a warning without the values. - Decode RFC 2047 encoded words with
mime.WordDecoder, charsetsutf-8andus-asciionly; any other charset or a decoding error is treated as absent. - The decoded value must be valid UTF-8 without control, format or bidi characters and not empty; otherwise absent.
name_headerfollows the same rules; failure only drops the display name.
loginWithTrustedHeader (POST /api/v1/auth/trusted-header/login, anonymous,
auth.login.complete, rl.public_auth, header DPoP without ath, no Idempotency-Key):
authorize(anonymous, auth.login.complete, instance).- Driver off:
404 not_found. - Login absent:
401 unauthenticated, audit deny (ID-T09). - External identity:
ProviderID,Issuerempty,Subject= decoded login,DisplayName= decoded name or the login. - One Unit of Work: lookup
user_identitiesby(provider_id, subject)withunlinked_at IS NULLandissuer IS NULL; else optionalenrollment_codein the body (new field, specification first): verify and consume through M1-37, insert the identity, auditidentity.link, deviceactive(08 3.2); elseallow_provisioning: person and devicepending,users.emailNULL, handle derived from the part of the login before@with the rule of M1-41 (reuse its helper if merged, otherwise implement the same rule indomain/trustedheaderand note it for consolidation); else rollback,401 unauthenticatedwith the neutral body, audit deny in its own transaction. A wrong enrollment code is a401with the same body (no hint which part failed). - Principal
disabledordeleted:401. Otherwise device registration, session (auth_method = 'trusted_header',identity_id) and token pair through the shared functions;last_login_at = now; audit allow with details{provider_id, device_id}(never the login). 201 TokenResponse,Cache-Control: no-store.
Per-request binding (only when auth.trusted_header_bind_requests = true): in the
authenticator, after the access token and DPoP checks succeed, for a session with
auth_method = 'trusted_header': evaluate the header as above and compare the decoded login
byte-exact with user_identities.subject of auth_sessions.identity_id (load it with the session
lookup; an identity that was unlinked counts as mismatch). Missing or different: revoke the
session with the reason from precondition 1 (family revoked, session.revoked written), audit
deny with deny_reason = 'session_revoked' and the route's action, answer 401 session_revoked.
The check runs for every request authenticated with such a session, including refreshToken,
because the authenticator is shared. With the switch off, no header is read after the login.
Readiness versus start failure (W4). 16 4.3 applies: the driver is a runtime setting of the
owner, so a misconfiguration turns the driver off and fails readiness instead of preventing the
start. 08 2.4 rule 1, ID-T08 and SEC-T01 say so; record W4 in docs/DECISIONS.md with a link.
Tests (write them first)¶
Unit tests (domain/trustedheader):
TestIsLoopbackListener:127.0.0.1,127.5.5.5,::1true;0.0.0.0,::,192.0.2.10,2001:db8::1false (constructednet.TCPAddrvalues).TestDecodeHeaderValue: plain ASCII;=?utf-8?q?R=C3=B6bin?=decodes to "Robin" with U+00F6 (o with diaeresis) as the second letter; base64 encoded word; unsupported charset absent; control and bidi characters absent; empty absent.TestTrustedHeaderConfigValidation: valid names; invalid token characters; unknown field.
Integration tests (PostgreSQL 18, real HTTP chain on 127.0.0.1:0; the peer is 127.0.0.1, so
"trusted" uses server.trusted_proxies = 127.0.0.1/32 and "untrusted" uses 192.0.2.0/24;
the loopback flag is injected into the identity config for the off cases):
TestIDT08_DriverOffWithoutLoopback: flag false with an enabled provider:/readyz503 namingtrusted_header_requires_loopback, login404;updateIdentityProviderenabling andcreateIdentityProviderboth409with the reason; with the flag true and the provider enabled/readyzis ready.TestIDT09_MissingLoginHeader401: no header (tagged device simulated) gives401and an audit deny.TestSECT01_HeaderFromUntrustedPeerIgnored: same request with an untrusted peer gives401even with a valid header.TestIDT10_BindingMismatchRevokesSession: login asalex@example.com, then a request withsam@example.comgives401 session_revoked; the session hasrevoked_atand the reason, the refresh family is revoked,session.revokedexists in the event log, the audit entry exists, and a following refresh fails.TestBinding_MissingHeaderRevokesandTestBinding_OffDoesNotReadHeader.TestTrustedHeaderLogin_KnownIdentity,TestTrustedHeaderLogin_EnrollmentCodeLinks(deviceactive, code used),TestTrustedHeaderLogin_ProvisioningPending,TestTrustedHeaderLogin_NoMappingNeutral401,TestTrustedHeaderLogin_DisabledPerson401,TestTrustedHeaderLogin_MultipleValuesAbsent.TestIDT25_ForwardedForNotTrustedstays green (rate limit counts the real peer; reuse of M1-18).TestTrustedHeaderNeverLogged: log and audit capture contain no header value.- Route count (AZ-P1) and response validation pass with the operation in the allow-list.
Commands that must pass:
cd services/server && go test -race ./internal/identity/... ./internal/platform/... ./internal/wire/...
make codegen-check
make arch
make api-lint
make docs
make check
Acceptance criteria¶
- Preconditions verified and named in the report.
- ID-T08 (after W4), ID-T09, ID-T10 and SEC-T01 for the driver green with the names above; T-ID-02 in the threat model names them.
- Header evaluated only with a loopback public listener and a trusted TCP peer; forwarded addresses never count.
- RFC 2047 values decoded; the login, not the name, is compared.
- Binding revokes the session with a reason, an audit entry and
session.revoked. - Header values never in logs, audit details or problem bodies.
- The implementation matches 08, 16, 19 and
openapi.yaml(changed only where proven wrong);operations.yaml,tailscale.md, DECISIONS (W4) updated;make checkgreen.
Definition of done¶
- Code and tests as above; every public symbol has a doc comment (purpose, parameters, return value, errors, example where useful); comments explain why.
internal/identity/README.mdcurrent; CHANGELOG entry under "Unreleased";docs/DECISIONS.mdentries; no ADR unless a decision beyond ADR 0019 was taken (then MADR 4 andmake adr-index).make checkgreen.- Independent review (
make reviewand a code review over the diff) and the red team gate over the diff, verdict recorded in the report; red team item 4 of the M1 section (forged proxy headers) applies directly. - Merge locally into the base branch only if review and red team are green and
make checkis green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, the test results with counts (including the red runs), what
is not proven (at least: behavior behind a real tailscale serve, WebSocket upgrade binding),
and follow-ups that could not be done in scope with the reason (at least the gateway binding test
for M1-64). Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.