Section 1What this solves
A Tempest weather station's data sits behind its owner's WeatherFlow account, so before the app can show anyone their own backyard weather, it needs an access token for that account. The first version shipped with the most direct answer available: bring your own API key.
It worked, and it was clunky. You signed in to WeatherFlow on a phone or a laptop, found the token, and then typed it into an Apple TV with a remote. Tokens are long, opaque, and unforgiving of a single wrong character. The field is a secure text field because the alternative is a credential sitting in plain view on a screen the whole room can see. Plenty of people got through it. It is not something you would choose to put in front of someone.
So the question became: what does the friendly version look like, without making the security worse? On a phone this is a solved problem. You tap a button, a web view opens, you sign in to WeatherFlow, and the app receives a token without ever seeing your password. That is ordinary OAuth, and it is genuinely better than typing a key, because the app never handles the credential at all.
On a television, the obvious route to that is closed.
tvOS has no WKWebView. There is no way for
the app to render WeatherFlow's sign-in page, which means the consent
step has to happen on a device that does have a browser. In practice
that is the phone already in the room. The design problem turns into a
delivery problem: once the phone has finished signing in, how does the
resulting authorization code get back to the TV?
The conventional answer is to point the redirect at a server the developer runs, which catches the code and relays it to the device. We did not want to be in that path. This sign-in is between the customer and WeatherFlow, about the customer's own weather station, and inserting our infrastructure into the middle of it means standing between two parties who have no need of a third. If that can be avoided, it should be.
So we looked for a way to keep the redirect entirely inside the customer's home. That is what the rest of this note describes: the redirect points at the Apple TV itself, which means the phone's browser needs a name it can resolve for a device that has no DNS name, and a listener waiting behind it.
Section 2The shape of the trick
The TV publishes an mDNS A record for
compositesky.local pointing at its own IPv4 address, binds
a plain HTTP listener on a known port, and puts the resulting authorize
URL on screen as a QR code. The phone scans it, consents at
WeatherFlow, and is redirected to
http://compositesky.local:8815/callback?code=…. That
request resolves over multicast DNS to the TV sitting in the same room,
and the TV's own listener catches it. The hostname exists only for the
length of the pairing window.
The verifier half of the PKCE pair is generated on the TV and never leaves it. That is what makes the plaintext hop survivable, and section 5 works through why.
The orchestration is a single pairing-session type: three steps to start, then a wait for a matching callback. The ordering in the start step matters: the hostname is published before the port is bound, and if the bind fails the hostname is withdrawn again before the error propagates. There is no window in which a name is being defended for a listener that does not exist.
The PKCE material is one value type: a 48-byte random verifier, its
base64url SHA-256 challenge, and a 16-byte random state,
all drawn from SecRandomCopyBytes. Only the challenge and
the state go into the authorize URL.
The QR is rendered with Core Image's QR generator at correction level “M”: higher correction adds modules and shrinks each one, which costs scanning range, and range is what matters when the phone is on a couch and the QR is on a TV across the room.
Section 3The ephemeral hostname
The mDNS host registers the A record directly with
mDNSResponder rather than going through NWListener's
Bonjour service advertisement, because what is needed is a
hostname that resolves, not a service anyone browses for. That
is DNSServiceCreateConnection followed by
DNSServiceRegisterRecord.
Details that matter:
-
kDNSServiceFlagsUnique: the record is registered as the unique owner of the name, so the daemon probes the network for a conflicting claim before confirming. On a quiet network that probe takes roughly 750 ms. - TTL 30 s, publish timeout 5 s: both are defaults on publish. The TTL is deliberately short because withdrawal is not instant. See section 7.
-
en0is preferred for the address. The address lookup walks the interface list and returnsen0if it is up and non-loopback, otherwise the first other non-loopback IPv4 interface it finds. On an Apple TVen0is the Wi-Fi or Ethernet interface. -
Everything is driven synchronously on the calling
thread: a
poll(2)on the daemon socket plusDNSServiceProcessResult. No dnssd callback ever runs concurrently with anything else, which is why the callback context objects need no locking.
Because that pump blocks, the publisher runs the whole publish inside a detached task and only touches shared state for the pointer swap afterwards. The lock is never held across the blocking registration call. It also carries a tombstone for the race where a withdrawal lands while the detached task is still probing: the post-await swap discards the freshly-minted host instead of storing it.
Publishing these records and accepting inbound connections on tvOS is
not gated behind the
NSLocalNetworkUsageDescription prompt, and the app
correctly declares neither that key nor
NSBonjourServices. The platform support table in Apple's
TN3179,
Understanding local network privacy, has tvOS as unsupported,
with no version at which it was introduced. tvOS does not implement
local network privacy at all. The one caveat worth knowing is that a
platform can acquire it later: macOS only gained local network
privacy in macOS 15.
Section 4Catching the redirect
The callback server is an NWListener wrapping the smallest
HTTP server that will do the job.
Port fallback
It tries 8815, then 8816, then 8817, taking the first that binds. All
three are registered as redirect URIs with WeatherFlow, because
WeatherFlow matches both host and port exactly. There is no wildcard to
fall back on, so each candidate port needs its own registration. The
port that actually binds is what builds redirect_uri, and
the same string is sent again in the token exchange, as the OAuth spec
requires.
Binding is async for a non-obvious reason:
NWListener reports address-in-use asynchronously,
through a .failed state transition rather than a throwing
initializer, so a bind attempt has to await the first ready-or-failed
transition before it knows whether it won the port.
The three-way classifier
Every request head is sorted into exactly one of three kinds.
| Kind | Matches | Page served | Ends the window? |
|---|---|---|---|
.callback |
GET /callback with a non-empty code |
paired | Yes: yields on the stream |
.denied |
GET /callback with an error param and no usable code |
not paired | No |
.other |
Anything else | bland | No |
Two things about that table are deliberate and easy to get backwards.
-
A usable
codebeats anerrorparam. The code check runs first and only falls through to the error check if no non-emptycodewas present. A redirect carrying both still pairs. -
Only
.callbackends the pairing window. The other two are answered with a page and nothing else: no yield on the stream. A favicon fetch from the phone's browser, a stray probe from something else on the LAN, or the user declining at WeatherFlow all leave the window running, because the TV is still showing the QR and the user may still be about to succeed.
The three pages are entirely self-contained (no
<link>, no <script>, no remote
src) because the listener is ephemeral and on a local
network, and a page reaching for a CDN would make the last step of
pairing fail for reasons unrelated to pairing.
Then the state check
A yielded callback still has to match the session's state
before it counts. A mismatch is skipped and the loop keeps waiting. A
stray or replayed redirect must not be able to end a live pairing
window. WeatherFlow echoes state verbatim, which was
measured rather than assumed.
Section 5Why plaintext HTTP is not the hole it looks like
The redirect URI really is http://, not
https://. The authorization code crosses the LAN in the
clear, in a URL query string, where anything else on that network can
read it. That is a real property of this design, not an oversight, and
it is worth being precise about what it does and does not cost.
HTTPS was never on the table. A TLS listener needs a certificate for
compositesky.local, and there is no way to obtain a
legitimate one for a name on someone else's Apple TV.
RFC 6762
reserves .local for multicast DNS precisely because those
names are local, not global, so no certificate authority can vouch for
one. The only alternative, a self-signed certificate, puts a full-page
browser warning in front of the last step of pairing, which is worse
for the user than the thing it would be protecting.
So plain HTTP on the local network is what the platform gives us to work with. The question is what to build on top of it, and the answer is PKCE.
Concretely: the TV generates 48 random bytes and sends only their SHA-256 hash to WeatherFlow, inside the authorize URL. WeatherFlow stores that challenge against the code it issues. Redeeming the code at the token endpoint requires presenting the preimage (the raw verifier), which is sent once, over HTTPS, from the TV. An attacker who reads the code off the LAN holds one half of a pair whose other half was never transmitted in the clear and would have to be brute-forced out of a 384-bit space. WeatherFlow answers such an attempt with 400 or 401 and issues no token. (When the app receives that same 400 or 401, for a code that was already used, or a genuinely bad one, it maps it to a “token rejected” error. An attacker is not running the app, so that mapping is the app's own error handling and nothing more.)
Being honest about what remains
- The code is visible, and codes are single-use: WeatherFlow returns 400 or 401 for a code that has already been redeemed. An attacker on the LAN can therefore race the TV to redeem it, but there is nothing to win, because redemption requires the verifier either way. The realistic damage is denial of service: an attacker who floods the listener or sabotages the redirect can stop pairing from completing. They cannot make it complete into their own account, and they cannot obtain the user's token.
-
The attacker must already be on the LAN. The
listener is bound for the length of one pairing window and sorts
every request it can read into one of three shapes, each answered
with a fixed
HTTP/1.1 200 OKcarrying static HTML and no dynamic body. There is no persistent surface. - This is the LAN's threat model, not the internet's. An attacker with the ability to sniff this hop is an attacker already inside the user's home network, next to a TV.
The list above models a passive eavesdropper. An active attacker on the same LAN has two more things to try, and a third attack needs no network access at all. None of the three yields the user's token, but they are worth naming rather than leaving implicit.
-
Squatting the hostname. An attacker who claims
compositesky.localbefore the TV does sends the phone's redirect to themselves instead. ThekDNSServiceFlagsUniqueregistration in section 3 means the TV probes for a conflicting claim and fails to publish rather than silently sharing the name, so the visible effect is a “hostname unavailable” failure and a fallback to manual entry. If the squat succeeds, the squatter ends up holding an auth code whose verifier they do not have, the same dead end as the passive case. -
Injecting a code. Because the redirect is plaintext,
a LAN attacker learns the
statevalue along with the code, and can send the TV's own listener aGET /callbackcarrying their own auth code and the sniffedstate. It passes the classifier and the state check and the TV will try to redeem it: against its verifier, not the attacker's, so WeatherFlow rejects the mismatch and pairing fails. The TV cannot be induced to pair with the attacker's account this way.stateis doing CSRF-style binding here, not confidentiality. This write-up never claims otherwise, and a reader should know the attacker has it. - Scanning the QR. Anyone who can see the TV screen (or a photo or screen-share of it) can scan the authorize URL and consent with their own WeatherFlow account. The TV then pairs successfully to a stranger's station and starts showing the wrong weather. No token of the user's is exposed and the fix is to disconnect and re-pair, but this is the one attack that needs neither LAN access nor any cryptography, and it is the first thing a reader looking at a QR on a television will think of.
The two hops that carry secrets (the consent flow on the phone and the
token exchange from the TV) are both HTTPS to WeatherFlow. The token
response body carries the access token, which is why nothing in the
exchanger may log a request or response body, and the token lands in
the Keychain, never in UserDefaults.
Section 6When it goes wrong
Failures split into two groups, and the split is the interesting part: some end the pairing window and drop the user into manual token entry with a one-line hint, while others deliberately leave the window running because the TV is still showing a scannable QR and the user has lost nothing.
There are exactly three pairing failure cases.
hostnameUnavailable is thrown when publish fails for any
reason: the underlying mDNS failure is flattened away, because from the
setup screen's point of view a service error and a timeout lead to the
same place. portsBusy comes from exhausting the port list.
listenerClosed is what the await step throws if the stream
finishes without a matching callback.
The hint lines differ by phase: a start-up failure gives “Pairing unavailable — enter a token instead”, a failure while awaiting the callback gives “Pairing failed — enter a token instead”. Choosing manual entry deliberately sets no hint at all: the user made that choice, so there is nothing to explain.
One failure mode is handled after pairing rather than during it: WeatherFlow returns a transient HTTP 429 on its stations endpoint immediately after a token is created. The model retries up to 3 attempts with a 2 s wait. Without it, a successful pairing would land on the “could not reach Tempest” screen.
Section 7Teardown
One cancel() method stops the listener and withdraws the
hostname. Every teardown path goes through it:
- Success: before the token exchange
- Pairing start-up failed
- The await step failed
- User chose manual entry
- Screen exit
- A newer session supersedes this one
The success path is worth calling out because the ordering is
deliberate and looks wrong at first glance: cancel() runs
before the token exchange, not after it. The window is closed
while the app still holds the code in memory, so the listener and the
published hostname are gone before any network call to WeatherFlow
begins. The exchange does not need them (it is an outbound HTTPS POST),
so keeping them alive would only widen the window during which the name
is on the LAN.
Supersession is the subtle one. A second pairing attempt while a prior
session is still starting up would otherwise orphan it: a live listener
and a published hostname that the cancel path can no longer reach,
because the session reference now points elsewhere. So every site that
reassigns the session cancels the old one first, and identity checks
after each await let a superseded session return quietly,
knowing it was already cancelled by whoever replaced it.
A crash mid-pairing self-cleans: mDNSResponder tears down the records
of a client connection that has died. Withdrawal is safe to call more
than once and also runs from deinit.
Withdrawal is not instant
Removing the record only stops the daemon defending the name.
Passive mDNS caches (other daemons, other hosts on the network) may
keep answering for compositesky.local for up to the
record's TTL if the goodbye multicast does not reach them. That is why
the pairing TTL is 30 s rather than something longer: it is the
intended width of the stale window.
The 30 s is what this app puts in its own record, and a foreign daemon is not obliged to honour it. The test suite records observing passive caching of overheard announcements at a TTL of 120 s. Whether that reflects a daemon default applied regardless of the advertised TTL, or something specific to those runs, is not settled anywhere in this project.
So treat 30 s as the floor of the stale window rather than the
ceiling: in practice a withdrawn compositesky.local may
keep resolving somewhere on the network for longer, and nothing in
the design depends on it not doing so.
None of this is merely believed. It is what the tests decline to assert: the live-mDNS suite deliberately does not check that a withdrawn name stops resolving, precisely because mDNS disappearance is best-effort, and asserts only that the daemon accepted the removal.
Section 8What is deliberately not done
No revoke call
Disconnecting a Tempest account deletes the Keychain token, clears the station selection and drops the station's cached records, but it does not revoke the OAuth grant at WeatherFlow. This app calls no revoke endpoint, so the grant lives on until the user removes it at tempestwx.com. The confirmation copy on screen says so in as many words, rather than implying a cleanup that does not happen.
The location is kept on purpose
Disconnect does not clear latitude, longitude or location name. The station pick wrote them and they are the app's only location: blanking them would leave the app unconfigured and stop alerts and radar as well as weather.
No retry loops on pairing setup
A publish or bind failure goes straight to manual entry rather than retrying, because the fallback is right there and a spinner that might resolve is worse than a clear door out.
One TV, one registered hostname
compositesky.local is a fixed name registered with
WeatherFlow, not a per-device one. Two Apple TVs pairing on the same
LAN at the same moment would collide on the name. Pairing is a
seconds-long, deliberate act that happens once per TV, so this is an
accepted limit rather than a bug waiting to be found.
Closing noteThis describes the code, not the plan
Everything above is a description of the shipping implementation. An earlier internal design record predates the code and has since drifted from it in several places: the number of pages the listener serves, the point at which the window is torn down relative to the token exchange, the names of the setup states, and the existence of an account-disconnect path. Where the two disagree, the code is authoritative and this page follows the code.