Repository navigation
Description
Branch: build this on
custom-v2(the custom build, currently atb1ba5788), notmain.mainis a plain mirror of upstream and has no trusted-device feature.Do not deploy. Installing the new build restarts T3 Code on the owner's Mac, which ends any remote session he has open. Open a pull request against
custom-v2and stop.
Problem Statement
I reach my machines (my Mac, my VPS) from my iPhone over Tailscale. In Safari, I open any machine and I am logged in, because my iPhone is on that server's trusted-device list. The native T3 Code iPhone app does not get that trust. It demands a fresh pairing code for every machine, which I can only mint while sitting at that machine. When I am away from home, I cannot add a machine to the app at all, and Safari gets a privilege the app is refused for no reason.
Solution
When the iPhone app pairs with a server from a trusted device, the server accepts any pairing code and logs the app in. I add each machine to the app once: I type its Tailscale host name and any word (for example ok) in the code box. Devices not on the trusted list pair exactly as they do today, with a real code. An empty trusted list means nothing changes for anyone.
User Stories
- As the owner, I want to add my Mac to the iPhone app by typing its host and any word, so that I never need to mint a pairing code at the Mac.
- As the owner, I want to add my VPS the same way, so that every machine on my tailnet works alike once it runs this build.
- As the owner, I want the app's trust to follow the same trusted-device list Safari already uses, so that I manage one list, not two.
- As the owner, I want a device that is not on the trusted list to still need a valid pairing code, so that the shortcut grants nothing to strangers.
- As the owner, I want a device on my tailnet but not on the list to be refused when it sends a made-up code, so that tailnet membership alone is not enough.
- As the owner, I want an empty trusted list to mean everyone pairs as before, so that the feature is off unless I turn it on.
- As the owner, I want the app's trusted login to show up in the server's list of connected clients with a label naming the device, so that I can see and revoke it.
- As the owner, I want the app's trusted login to have the same permissions a normally paired app gets, so that every app feature works.
- As the owner, I want the app's trusted login to expire on the same schedule as a normal app login, so that trust does not last forever.
- As the owner, I want the trusted path to work both when the app connects straight over the tailnet and when it connects through Tailscale Serve, so that either host address I type works.
- As the owner, I want a request that arrives through a proxy that is not the local Tailscale Serve to be treated as untrusted, so that a forged forwarding header cannot claim to be my phone.
- As the owner, I want a real, valid code typed on a trusted device to still work, so that nothing I already do breaks.
- As the owner, I want a wrong or expired code from an untrusted device to give the same error as today, so that the app's existing error handling still applies.
- As the owner, I want to remove my iPhone from the trusted list and have new pairings from it need a real code again, so that I can withdraw the shortcut.
- As the owner, I want the trusted app login to work whether or not the app sends a DPoP proof (a signed token that binds the login to the phone), so that the token type matches what the app expects.
- As the owner, I want the server's logs or traces to show that a login came through the trusted-device path, so that I can tell it apart from a code-based pairing.
- As a maintainer, I want the device-identity rules to live in one place shared by the browser path and the app path, so that the two can never drift apart.
Implementation Decisions
- Where the trust check runs: in the native app's pairing exchange, which is the OAuth-style token exchange where the pairing code travels as the subject token. That handler already has the incoming request. It already has the trusted-devices service in scope. Before the code is validated, it asks the trusted-devices service to resolve the caller. When a device name comes back, it skips code validation and issues a session. When none comes back, it runs the existing code path unchanged.
- Any code is accepted. Any non-empty code is accepted from a trusted device. A fixed secret word was rejected because it adds no security: identity already comes from Tailscale. The app and the request schema both refuse an empty code, so the user still types something. That is accepted.
- A real code sent from a trusted device is not consumed. The trusted path does not look at the code. An unused real code expires on its own.
- The session shape mirrors the browser trusted session. The subject is
trusted-device:<name>, the scopes are the standard client scopes, and the client label isTrusted device: <name>. The client metadata is derived from the request the same way the normal exchange derives it. If the app asks for narrower scopes, the same scope narrowing the normal exchange applies is honoured. - The token response is the same as a normal pairing. It returns an access token of type
Bearer, orDPoPwhen the request carries a DPoP proof. It returns the same expiry fields. It sets no cookie. - A new issue function on the environment-auth service. It is the bearer-token counterpart of the existing trusted-device browser-session function. It takes the device name, the request metadata, an optional DPoP binding and the requested scopes, and it returns the token-exchange response. The existing browser-session function is not changed.
- Device identity rules are not changed. They are reused exactly as the browser path uses them:
- For a direct tailnet peer, the server asks Tailscale's whois about the socket address.
- A single-IP forwarding header is trusted only on loopback, where Tailscale Serve sits.
- The MagicDNS name must equal
<entry>.<tailnet suffix>.
- The settings schema is not changed. The existing
trustedTailscaleDevicessetting drives both paths. - Revocation is the same as today's browser trust. Removing a device from the list blocks new trusted logins. It does not end sessions that already exist; those last until they expire or are revoked from the connected-clients list.
Testing Decisions
- One seam: the HTTP auth endpoints. Tests send real requests to the token-exchange endpoint and assert on the response. They do not call internal functions.
- Prior art: the existing auth HTTP tests already fake the trusted-devices service through a test-only request header and test the browser session endpoint that way. The new tests reuse that fake. Identity resolution itself is already covered by the trusted-devices unit tests, so it is not retested here.
- Cases:
- A trusted device sends any code and gets an access token whose session carries the trusted-device subject and the standard scopes.
- An untrusted device sends a made-up code and gets the same rejection as today.
- An untrusted device sends a valid code and gets the normal session; the code is consumed.
- A trusted device sends a valid code, gets the trusted session, and the code stays unconsumed.
- A trusted device sends a DPoP proof and gets a DPoP-typed token.
- An empty trusted list means a made-up code is rejected.
- What makes a good test: it checks only external behaviour, meaning the status code, the response body and the session as the server reports it. It does not check which internal function ran.
- Run only the auth test files locally. The full suite runs in GitHub Actions.
Out of Scope
- Ending existing sessions when a device is removed from the trusted list. That needs its own feature, and it would have to cover the browser path too.
- Any change to the iPhone app. The app belongs upstream and is not ours. That includes letting it send an empty code and auto-discovering tailnet machines.
- T3 Connect or any relay service.
- Deploying the build to the Mac or the VPS. The owner installs it himself when he is at the Mac.
- Changing the device identity rules or the settings UI.
Further Notes
- Security: this grants trusted devices in the app exactly what they already get in the browser. It grants nothing to an untrusted device.
- The App Store build of the app reported "Client not supported" against this server; the TestFlight build is the one that pairs. Verify against the TestFlight build.
- The VPS benefits only after it runs a build that includes this change.
Activity
ziyadakl commentedon Oct 7, 2026
Done in #3, merged into custom-v2.