Iniciar sesión en un casco de RV como lo hace tu televisor
Bannermarch recibió soporte de RV esta semana. Abres el juego en el navegador Meta Quest, pulsas el botón y te encuentras dentro de tu sala. Antes de nada, sin embargo, tienes que iniciar sesión. En un Quest, eso significa apuntar un láser a un teclado flotante y teclear tu correo electrónico letra a letra, luego tu contraseña, que probablemente esté en un gestor de contraseñas en tu teléfono. Así que te quitas el casco para ir a buscarla. (Escribí sobre cómo funciona la realidad virtual en el navegador en otro artículo.)
Las televisiones resolvieron esto hace años. La aplicación de YouTube en una TV te muestra un código, vas a una URL en tu teléfono, lo introduces y la TV inicia sesión sola. Netflix lo hace, la PlayStation lo hace. Incluso hay un RFC para ello (RFC 8628, la concesión de autorización de dispositivo OAuth). Construí lo mismo para el juego. El casco muestra un código y tú lo apruebas en /pair desde un teléfono o portátil donde ya hayas iniciado sesión.
Explicaré cómo funciona, porque es una pequeña y agradable pieza de diseño de sistemas y la mayoría de la gente la ha usado sin pensar en lo que hay detrás.
¿Quién habla con quién?
El casco y tu teléfono nunca hablan entre sí. No hice nada ingenioso con Bluetooth o la red local. Ambos hablan con el servidor del juego, y el servidor guarda un código pendiente durante diez minutos. Tú eres el único enlace entre las dos pantallas. Lees ocho caracteres de una y los escribes en la otra.
Casco Servidor Teléfono
| | |
|-- iniciar ---------------->| |
|<-- código K7MP2QXR ---------| |
| + secreto en una cookie| |
| | |
|-- consultar (aún esperando)>|<-- abrir /pair, escribir código -|
| |--- "Meta Quest, Miami" -->|
| |<-- aprobar ---------------|
|-- consultar + secreto ---->| |
|<-- sesión iniciada ---------| |
Aquí está el orden en que ocurren las cosas.
El casco solicita al servidor que inicie un emparejamiento. El servidor genera un código corto para que lo leas, como K7MP2QXR. También genera un secreto aleatorio largo, de 32 bytes, y lo envía de vuelta al casco en una cookie. El servidor almacena un hash de ese secreto, no el secreto.
El casco muestra el código en pantalla y empieza a preguntar al servidor cada 2.5 segundos si alguien lo ha aprobado. También hay un código QR en pantalla, que apunta a /pair#K7MP2QXR. Eso funciona muy bien en una TV o un portátil. En un casco es bastante inútil, pero ya llegaré a eso.
Abres /pair en tu teléfono y escribes el código. El servidor lo busca y te muestra qué dispositivo está solicitando y aproximadamente dónde se encuentra. Adivina "Meta Quest" a partir del User-Agent del navegador y obtiene una ciudad y país a partir de la IP. Pulsas aprobar. El servidor escribe tu ID de cuenta junto al código pendiente.
La siguiente consulta del casco envía la cookie. El servidor hashea el secreto, lo compara, ve la aprobación, elimina la entrada pendiente y otorga al casco su propia sesión. Ya estás dentro del juego.
¿Por qué un código robado es inútil?
El código está en una pantalla, así que supongo que otras personas pueden leerlo. Tu hermano en el sofá puede. Cualquiera que esté viendo una transmisión también.
Esa es la razón del secreto. Cuando apruebas, tu cuenta no va a quienquiera que sepa el código. Va al navegador que tiene el secreto, y ese navegador es el casco. La cookie es HttpOnly, por lo que JavaScript en la página no puede leerla, y SameSite=Strict, por lo que otro sitio no puede hacer que el navegador la envíe. Si alguien copia el código de tu TV, no puede conseguir nada con él.
La sesión se puede obtener una vez. El servidor elimina la entrada pendiente en el momento en que el casco la recoge, y cualquier consulta posterior recibe "expirado". Si nadie aprueba el código en diez minutos, de todos modos desaparece.
No me preocupé mucho por las suposiciones. Los códigos usan 31 caracteres (eliminé 0, O, 1, I y L porque la gente los confunde), y ocho de ellos dan unos 850 mil millones de combinaciones. La aprobación está limitada por tasa además de eso, y cada código solo dura diez minutos.
Phishing
El secreto no ayuda si apruebas el código equivocado. Digamos que alguien inicia un emparejamiento en su propio portátil y te envía un mensaje "introduce este código para confirmar tu cuenta". Lo apruebas, y ahora su portátil tiene tu sesión. Se llama phishing de código de dispositivo. El RFC lo advierte, y los atacantes lo han utilizado contra cuentas de Microsoft 365.
No puedo detenerlo por completo, pero puedo hacerlo obvio. La pantalla de aprobación nombra el dispositivo y el lugar desde el que se conecta, con una advertencia de aprobar solo un código que puedas ver en un dispositivo que tengas en la mano. Si vives en Miami y dice "PC con Windows" en algún país en el que nunca has estado, probablemente te darás cuenta.
Los cascos no son TVs
Unos veinte minutos después de publicar la primera versión, publiqué una segunda. Con una TV puedes mirar el código y tu teléfono al mismo tiempo. Con un casco puesto no puedes ver tu teléfono en absoluto, así que terminas levantando el casco para leer el código, tecleándolo y volviéndotelo a poner. El código QR tampoco funciona, porque la cámara de tu teléfono no puede ver dentro de las lentes.
Así que ahora también va al revés. En /pair hay un botón "Obtener un código para tu casco". Tu teléfono solicita al servidor un código vinculado a tu cuenta, lo lees, te pones el casco y lo introduces en la pantalla de inicio de sesión. Ocho caracteres con el teclado láser sigue siendo molesto. Es mucho menos molesto que un correo electrónico y una contraseña.
Esta versión es menos segura que la primera. No hay secreto detrás del código, así que quien lo escriba primero entra. La mantuve de todos modos y la acoté. El código solo se emite a una cuenta iniciada con un correo electrónico verificado. Funciona una vez y dura diez minutos, y la escritura de códigos está limitada por tasa.
También mantengo los dos tipos de códigos en tablas separadas. Si el cuadro "escribir un código" aceptara el código de un casco, entonces justo después de que aprobaras uno, cualquiera que lo hubiera visto en tu TV podría escribirlo en otro lugar y obtener tu sesión. Con tablas separadas, el código de un casco simplemente falla allí, aprobado o no.
Cómo está construido
Esa es la idea. Si quieres construir el tuyo propio, aquí tienes lo que hay realmente en el código. El servidor es un Cloudflare Worker en TypeScript. Solo la parte de almacenamiento es específica de Cloudflare, y diré qué sustituir.
Seis endpoints
| Endpoint | Llamado por | Qué hace |
|---|---|---|
POST /api/auth/device/start | Casco | Crea un código y un secreto, establece la cookie |
POST /api/auth/device/poll | Casco | Responde pendiente, expirado o inicia sesión en el casco |
POST /api/auth/device/lookup | Teléfono con sesión iniciada | Muestra qué dispositivo y lugar hay detrás de un código |
POST /api/auth/device/approve | Teléfono con sesión iniciada | Adjunta tu cuenta al código |
POST /api/auth/device/issue | Teléfono con sesión iniciada | Crea un código para el flujo inverso |
POST /api/auth/device/redeem | Casco | Canjea un código escrito por una sesión |
Todos ellos son POST y todas las respuestas tienen Cache-Control: no-store, por lo que nada con un código o una sesión en él se almacena en caché por el camino. Lookup y approve están divididos a propósito. El teléfono llama primero a lookup para mostrarte el nombre del dispositivo y el lugar, y solo llama a approve después de que lo hayas leído y pulsado el botón. Todo lo que llama un teléfono con sesión iniciada también comprueba si hay un correo electrónico verificado y pasa por un límite de tasa por cuenta.
Creando el código
El código proviene de crypto.getRandomValues, nunca de Math.random. Hay una pequeña trampa. Un byte aleatorio va de 0 a 255, y si solo tomas byte % 31, las primeras letras del alfabeto aparecen un poco más a menudo que el resto, porque 256 no se divide uniformemente por 31. La solución es desechar cualquier byte de 248 o superior y obtener otro. Eso se llama muestreo por rechazo, y son tres líneas adicionales.
const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 31 símbolos, sin 0 O 1 I L
function newPairingCode(): string {
// 256 no es múltiplo de 31, así que descarta los bytes superiores para que cada símbolo sea igual de probable
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;
}
// La gente escribe "k7mp-2qxr" o "K7MP 2QXR". Acepta ambos.
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;
}
El servidor también perdona cómo escribe la gente. Minúsculas, espacios y guiones se limpian antes de comprobar el código, para que nadie reciba un error por escribirlo como se ve.
El secreto vive en la cookie
Aquí está el manejador de inicio, un poco recortado.
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; // código ya tomado, genera otro
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: "Inténtalo de nuevo." }, { status: 503 });
}
Están sucediendo varias cosas. El servidor solo almacena secretHash. El secreto en bruto sale una vez, en la cookie, por lo que incluso alguien que lea la base de datos no puede hacerse pasar por el casco. La cookie lleva tanto el código como el secreto (K7MP2QXR.abc...), lo que significa que la solicitud de consulta no necesita cuerpo en absoluto. El navegador envía la cookie y el servidor sabe qué código comprobar y puede demostrar que es el casco correcto.
El prefijo __Host- le dice al navegador que esta cookie solo funciona sobre HTTPS en este dominio exacto, sin subdominios que puedan establecerla o sobrescribirla. Y ahí está el bucle de reintentos. Dos cascos podrían obtener aleatoriamente el mismo código, y cuando eso sucede begin devuelve null y el manejador genera uno nuevo. Con 850 mil millones de códigos posibles, básicamente nunca se repite, pero debe manejarse.
Una fila, varios estados
Cada código pendiente es una sola fila.
CREATE TABLE pairing (
id INTEGER PRIMARY KEY CHECK (id = 1), -- una fila por objeto, siempre
secret_hash TEXT NOT NULL, -- sha256 del secreto del casco
device_label TEXT NOT NULL, -- "Meta Quest"
place TEXT, -- "Miami, US"
expires_at INTEGER NOT NULL,
account_key TEXT -- NULL hasta que alguien apruebe
);
El estado de un emparejamiento proviene directamente de esa fila. Si account_key está vacío, está pendiente. Una vez que alguien aprueba, se rellena. Cuando el casco lo recoge, la fila se elimina. Si expires_at ha pasado, el código se considera expirado independientemente de lo que diga la fila. No hay columna de estado que mantener sincronizada, lo que es una forma menos de que algo salga mal.
Esto es lo que la consulta del casco termina llamando.
claim(secretHash: string) {
const row = this.pairing(); // null si falta o ha pasado 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"); // recoger una vez
return { status: "approved", accountKey: row.account_key };
}
Mira la primera comprobación. Un secreto incorrecto recibe la misma respuesta "expirado" que un código faltante. El servidor nunca le dice a un extraño "ese código existe, pero no eres el dispositivo correcto". En una recogida exitosa, la fila se elimina antes de que salga la sesión, por lo que la misma aprobación no se puede usar dos veces.
El lado del casco
El cliente es un pequeño bucle.
const poll = () => {
timer = setTimeout(async () => {
try {
const result = await api.pollDevicePairing();
if (result.authenticated) onSignedIn(result);
else if (result.status === "expired") showExpired();
else poll(); // todavía pendiente, preguntar de nuevo en 2.5s
} catch {
poll(); // el Wi-Fi inestable del casco no debería matar el emparejamiento
}
}, 2_500);
};
Utiliza setTimeout que se reprograma a sí mismo en lugar de setInterval. Si una solicitud es lenta, la siguiente espera en lugar de acumularse detrás de ella. Los errores mantienen el bucle en marcha, porque el Wi-Fi del casco se cae por un segundo de vez en cuando y eso no debería costarte el código. Cuando la consulta finalmente tiene éxito, la respuesta lleva la cookie de sesión normal, igual que un inicio de sesión con contraseña, y el juego se carga.
¿Dónde se almacenan los códigos?
El juego se ejecuta en Cloudflare Workers. Cada código obtiene su propio Objeto Duradero, nombrado según el código, que contiene una pequeña tabla SQLite con una fila.
Elegí eso debido a la regla de "recoger una vez". Un Objeto Duradero maneja sus solicitudes una a la vez, por lo que dos consultas para el mismo código no pueden obtener la aprobación. Cada uno también establece una alarma para un minuto después de que el código expire, y la alarma elimina su almacenamiento. No necesito un trabajo de limpieza.
Si no estás en Cloudflare, Redis hace el mismo trabajo. Almacena cada código como una clave con un TTL de diez minutos (SET ... EX 600 NX, donde NX te da la comprobación de "código ya tomado" gratis). Para el paso de recogida, comprueba el secreto, comprueba la aprobación y elimina la clave dentro de un script Lua para que todo suceda atómicamente. Una tabla Postgres normal también funciona si haces la reclamación como un único DELETE ... WHERE ... RETURNING y ejecutas una consulta de limpieza de vez en cuando.
El código QR pone el código después de un # en la URL. Los navegadores no envían esa parte al servidor, por lo que los códigos nunca aparecen en los registros de acceso. Y el casco consulta en lugar de mantener un WebSocket abierto. En el peor de los casos, son 240 solicitudes pequeñas durante diez minutos, lo cual no es nada, y no quería depurar WebSockets en el navegador Quest.