Inloggen met een VR-headset zoals je tv dat doet
Bannermarch kreeg deze week VR-ondersteuning. Je opent de game in de Meta Quest-browser, drukt op de knop en je staat in je basis. Maar voordat je dat kunt doen, moet je inloggen. Op een Quest betekent dit dat je een laser op een zwevend toetsenbord richt en één letter tegelijk je e-mailadres intypt, gevolgd door je wachtwoord, dat waarschijnlijk in een wachtwoordmanager op je telefoon staat. Dus je zet de headset af om te kijken. (Ik schreef in een aparte post over hoe de VR zelf in de browser draait.)
Televisies hebben dit jaren geleden al opgelost. De YouTube-app op een tv toont je een code, je gaat naar een URL op je telefoon, typt die in en de tv logt zichzelf in. Netflix doet het, de PlayStation doet het. Er is zelfs een RFC voor (RFC 8628, de OAuth device authorization grant). Ik heb hetzelfde gebouwd voor de game. De headset toont een code en je keurt deze goed op /pair op een telefoon of laptop waar je al bent ingelogd.
Ik zal uitleggen hoe het werkt, omdat het een mooi klein stukje systeemontwerp is en de meeste mensen het hebben gebruikt zonder na te denken over wat erachter zit.
Wie praat met wie
De headset en je telefoon praten nooit rechtstreeks met elkaar. Ik heb niets slims gedaan met Bluetooth of het lokale netwerk. Beide praten met de server van de game, en de server bewaart een wachtende code voor tien minuten. Jij bent de enige schakel tussen de twee schermen. Je leest acht tekens van het ene en typt ze in het andere.
Headset Server Telefoon
| | |
|-- start ----------------->| |
|<-- code K7MP2QXR ---------| |
| + geheim in een cookie | |
| | |
|-- poll (nog wachten) ---->|<-- open /pair, typ code -->|
| |--- "Meta Quest, Miami" ---->|
| |<-- goedkeuren ------------>|
|-- poll + geheim --------->| |
|<-- ingelogd --------------| |
Hier is de volgorde waarin dingen gebeuren.
De headset vraagt de server om een koppeling te starten. De server genereert een korte code die je kunt lezen, zoals K7MP2QXR. Het genereert ook een lang, willekeurig geheim, 32 bytes, en stuurt dit terug naar de headset in een cookie. De server slaat een hash van dat geheim op, niet het geheim zelf.
De headset plaatst de code op het scherm en begint de server elke 2,5 seconde te vragen of iemand deze heeft goedgekeurd. Er staat ook een QR-code op het scherm, die verwijst naar /pair#K7MP2QXR. Dat werkt prima op een tv of laptop. Op een headset is het vrij nutteloos, maar daar kom ik nog op.
Je opent /pair op je telefoon en typt de code. De server zoekt deze op en toont je welk apparaat vraagt en waar het zich ongeveer bevindt. Het raadt "Meta Quest" uit de User-Agent van de browser en haalt een stad en land op uit het IP-adres. Je tikt op goedkeuren. De server koppelt je account-ID aan de wachtende code.
De volgende poll van de headset stuurt de cookie mee. De server hasht het geheim, vergelijkt het, ziet de goedkeuring, verwijdert de wachtende vermelding en geeft de headset zijn eigen sessie. Je bent ingelogd in de game.
Waarom een gestolen code nutteloos is
De code staat op een scherm, dus ik ga ervan uit dat anderen hem kunnen lezen. Je broer op de bank kan het. Iemand die een stream bekijkt ook.
Dat is de reden voor het geheim. Wanneer je goedkeurt, gaat je account niet naar degene die de code kent. Het gaat naar de browser die het geheim vasthoudt, en die browser is de headset. De cookie is HttpOnly, dus JavaScript op de pagina kan hem niet lezen, en SameSite=Strict, dus een andere site kan de browser niet dwingen deze te verzenden. Als iemand de code van je tv kopieert, kunnen ze er niets mee verzamelen.
De sessie kan één keer worden verzameld. De server verwijdert de wachtende vermelding zodra de headset deze ophaalt, en elke latere poll krijgt "verlopen". Als niemand de code binnen tien minuten goedkeurt, is deze sowieso verdwenen.
Ik heb me niet veel zorgen gemaakt over gokken. De codes gebruiken 31 tekens (ik heb 0, O, 1, I en L weggelaten omdat mensen ze verwarren), en acht daarvan geven ongeveer 850 miljard combinaties. Goedkeuren is daarbovenop beperkt qua snelheid, en elke code is slechts tien minuten geldig.
Phishing
Het geheim helpt niet als je de verkeerde code goedkeurt. Stel dat iemand een koppeling start op zijn eigen laptop en je een bericht stuurt: "voer deze code in om je account te bevestigen". Je keurt het goed en nu heeft hun laptop jouw sessie. Dit heet device code phishing. De RFC waarschuwt ervoor, en aanvallers hebben het gebruikt tegen Microsoft 365-accounts.
Dat kan ik niet volledig stoppen, maar ik kan het wel duidelijk maken. Het goedkeuringsscherm toont de naam van het apparaat en de locatie waarvandaan het verbinding maakt, met de waarschuwing om alleen een code goed te keuren die je op een apparaat ziet dat je vasthoudt. Als je in Miami woont en er staat "Windows PC" in een land waar je nog nooit bent geweest, zul je het waarschijnlijk wel opmerken.
Headsets zijn geen tv's
Ongeveer twintig minuten nadat ik de eerste versie had geüpload, heb ik een tweede versie geüpload. Met een tv kun je tegelijkertijd naar de code en je telefoon kijken. Met een headset op kun je je telefoon helemaal niet zien, dus je eindigt met het optillen van de headset om de code te lezen, deze in te typen en hem weer op te zetten. De QR-code werkt ook niet, omdat de camera van je telefoon niet door de lenzen kan kijken.
Dus nu gaat het ook andersom. Op /pair staat een knop "Genereer een code voor je headset". Je telefoon vraagt de server om een code die aan je account is gekoppeld, je leest hem, zet de headset op en typt hem in het inlogscherm. Acht tekens met het laser-toetsenbord is nog steeds vervelend. Het is veel minder vervelend dan een e-mailadres en een wachtwoord.
Deze versie is minder veilig dan de eerste. Er zit geen geheim achter de code, dus wie hem als eerste intypt, komt binnen. Ik heb hem toch behouden en afgeschermd. De code wordt alleen uitgegeven aan een ingelogd account met een geverifieerd e-mailadres. Het werkt één keer en is tien minuten geldig, en het typen van codes is beperkt qua snelheid.
Ik houd ook de twee soorten codes in aparte tabellen bij. Als het "typ een code"-veld een code van een headset zou accepteren, dan zou direct nadat je er een hebt goedgekeurd, iedereen die hem op je tv heeft gezien, hem ergens anders kunnen typen en jouw sessie kunnen krijgen. Met aparte tabellen faalt de code van een headset daar gewoon, goedgekeurd of niet.
Hoe het is gebouwd
Dat is het idee. Als je je eigen wilt bouwen, hier is wat er daadwerkelijk in de code zit. De server is een Cloudflare Worker in TypeScript. Alleen het opslaggedeelte is specifiek voor Cloudflare, en ik zal zeggen wat je kunt vervangen.
Zes endpoints
| Endpoint | Aangeroepen door | Wat het doet |
|---|---|---|
POST /api/auth/device/start | Headset | Genereert een code en een geheim, stelt de cookie in |
POST /api/auth/device/poll | Headset | Antwoordt op wachtend, verlopen, of logt de headset in |
POST /api/auth/device/lookup | Ingeplugde telefoon | Toont welk apparaat en welke locatie achter een code zit |
POST /api/auth/device/approve | Ingeplugde telefoon | Koppelt je account aan de code |
POST /api/auth/device/issue | Ingeplugde telefoon | Genereert een code voor de omgekeerde stroom |
POST /api/auth/device/redeem | Headset | Ruilt een getypte code in voor een sessie |
Elk van deze is een POST en elke reactie heeft Cache-Control: no-store, zodat niets met een code of sessie onderweg wordt gecached. Lookup en approve zijn bewust gesplitst. De telefoon roept eerst lookup aan om je de apparaatnaam en locatie te tonen, en roept pas approve aan nadat je het hebt gelezen en op de knop hebt getikt. Alles wat een ingelogde telefoon aanroept, controleert ook op een geverifieerd e-mailadres en gaat door een limiet per account.
De code genereren
De code komt van crypto.getRandomValues, nooit van Math.random. Er is één kleine valkuil. Een willekeurige byte gaat van 0 tot 255, en als je gewoon byte % 31 neemt, komen de eerste paar letters van het alfabet iets vaker voor dan de rest, omdat 256 niet deelbaar is door 31. De oplossing is om elke byte van 248 of hoger weg te gooien en een nieuwe te trekken. Dat heet rejection sampling, en het zijn drie extra regels.
const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 31 symbolen, geen 0 O 1 I L
function newPairingCode(): string {
// 256 is geen veelvoud van 31, dus gooi de bovenste bytes weg om elk symbool even waarschijnlijk te houden
const limit = 256 - (256 % alphabet.length); // 248
let code = "";
while (code.length < 8) {
for (const byte of crypto.getRandomValues(new Uint8Array(16))) {
if (byte < limit && code.length < 8) code += alphabet[byte % alphabet.length];
}
}
return code;
}
// Mensen typen "k7mp-2qxr" of "K7MP 2QXR". Accepteer beide.
function normalizeCode(input: string): string | null {
const code = input.toUpperCase().replace(/[\s-]/g, "");
return code.length === 8 && [...code].every((c) => alphabet.includes(c)) ? code : null;
}
De server vergeeft ook hoe mensen typen. Kleine letters, spaties en streepjes worden allemaal opgeschoond voordat de code wordt gecontroleerd, zodat niemand een fout krijgt omdat hij het intypt zoals het eruitziet.
Het geheim leeft in de cookie
Hier is de start handler, een beetje ingekort.
async function startPairing(request: Request, env: Env): Promise<Response> {
const secret = base64Url(crypto.getRandomValues(new Uint8Array(32)));
const secretHash = await sha256Hex(secret);
for (let attempt = 0; attempt < 4; attempt += 1) {
const code = newPairingCode();
const expiresAt = await env.DEVICE_PAIRING
.getByName(`device-pairing:${code}`)
.begin(secretHash, deviceLabel(request), devicePlace(request));
if (expiresAt === null) continue; // code al genomen, genereer een nieuwe
const headers = new Headers({ "Cache-Control": "no-store" });
headers.append("Set-Cookie",
`__Host-bm_device_pairing=${code}.${secret}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=600`);
return Response.json({ code, expiresAt }, { headers });
}
return Response.json({ error: "Probeer opnieuw." }, { status: 503 });
}
Er gebeuren een paar dingen. De server slaat alleen secretHash op. Het ruwe geheim wordt één keer verzonden, in de cookie, dus zelfs iemand die de database leest, kan zich niet voordoen als de headset. De cookie bevat zowel de code als het geheim (K7MP2QXR.abc...), wat betekent dat het poll-verzoek geen body nodig heeft. De browser stuurt de cookie en de server weet welke code te controleren en kan bewijzen dat het de juiste headset is.
Het __Host- voorvoegsel vertelt de browser dat deze cookie alleen werkt via HTTPS op dit exacte domein, zonder dat een subdomein deze kan instellen of overschrijven. En daar is de retry-lus. Twee headsets kunnen willekeurig dezelfde code krijgen, en wanneer dat gebeurt, retourneert begin null en genereert de handler een nieuwe. Met 850 miljard mogelijke codes gebeurt dit vrijwel nooit, maar het moet worden afgehandeld.
Eén rij, een paar statussen
Elke wachtende code is een enkele rij.
CREATE TABLE pairing (
id INTEGER PRIMARY KEY CHECK (id = 1), -- één rij per object, ooit
secret_hash TEXT NOT NULL, -- sha256 van het geheim van de headset
device_label TEXT NOT NULL, -- "Meta Quest"
place TEXT, -- "Miami, US"
expires_at INTEGER NOT NULL,
account_key TEXT -- NULL totdat iemand goedkeurt
);
De status van een koppeling komt rechtstreeks uit die rij. Als account_key leeg is, is het in behandeling. Zodra iemand goedkeurt, wordt deze ingevuld. Wanneer de headset ophaalt, wordt de rij verwijderd. Als expires_at is verstreken, wordt de code als verlopen beschouwd, ongeacht wat de rij verder zegt. Er is geen statuskolom om bij te houden, wat een manier minder is om het mis te laten gaan.
Dit is wat de poll van de headset uiteindelijk aanroept.
claim(secretHash: string) {
const row = this.pairing(); // null als ontbrekend of na expires_at
if (!row || row.secret_hash !== secretHash) return { status: "expired" };
if (!row.account_key) return { status: "pending" };
this.ctx.storage.sql.exec("DELETE FROM pairing"); // één keer verzamelen
return { status: "approved", accountKey: row.account_key };
}
Kijk naar de eerste controle. Een verkeerd geheim krijgt hetzelfde "verlopen" antwoord als een ontbrekende code. De server vertelt een vreemde nooit "die code bestaat, maar jij bent niet het juiste apparaat". Bij een succesvolle verzameling wordt de rij verwijderd voordat de sessie wordt verzonden, dus dezelfde goedkeuring kan niet twee keer worden gebruikt.
De kant van de headset
De client is een kleine lus.
const poll = () => {
timer = setTimeout(async () => {
try {
const result = await api.pollDevicePairing();
if (result.authenticated) onSignedIn(result);
else if (result.status === "expired") showExpired();
else poll(); // nog in behandeling, vraag opnieuw over 2,5s
} catch {
poll(); // vluchtige headset wifi mag de koppeling niet verbreken
}
}, 2_500);
};
Het gebruikt setTimeout dat zichzelf opnieuw plant in plaats van setInterval. Als één verzoek traag is, wacht het volgende verzoek erop in plaats van erachteraan te komen. Fouten houden de lus gaande, omdat de wifi van de headset af en toe een seconde wegvalt en dat mag je de code niet kosten. Wanneer de poll eindelijk slaagt, draagt de reactie de normale sessiecookie mee, net als bij een wachtwoordlogin, en de game wordt geladen.
Waar de codes worden opgeslagen
De game draait op Cloudflare Workers. Elke code krijgt zijn eigen Durable Object, vernoemd naar de code, met daarin een kleine SQLite-tabel met één rij.
Ik heb daarvoor gekozen vanwege de regel "één keer verzamelen". Een Durable Object verwerkt zijn verzoeken één voor één, dus twee polls voor dezelfde code kunnen niet allebei de goedkeuring krijgen. Elk stelt ook een alarm in voor een minuut nadat de code is verlopen, en het alarm verwijdert zijn opslag. Ik heb geen opruimtaak nodig.
Als je niet op Cloudflare zit, doet Redis hetzelfde. Sla elke code op als een sleutel met een TTL van tien minuten (SET ... EX 600 NX, waarbij NX je de "code al genomen" controle gratis geeft). Voor de verzamelstap controleer je het geheim, controleer je de goedkeuring en verwijder je de sleutel binnen één Lua-script, zodat alles atomair gebeurt. Een gewone Postgres-tabel werkt ook als je het claimen doet als een enkele DELETE ... WHERE ... RETURNING en af en toe een opruimquery uitvoert.
De QR-code plaatst de code na een # in de URL. Browsers sturen dat deel niet naar de server, dus codes verschijnen nooit in toegangslogs. En de headset poll't in plaats van een WebSocket open te houden. In het ergste geval zijn dat 240 kleine verzoeken over tien minuten, wat niets is, en ik wilde geen WebSockets debuggen in de Quest-browser.