Android + web player: handle device_token authentication

Follows up on the security audit remediation (afbe113) which added
device_token auth to the WebSocket /device namespace.

Android player (ServerConfig.kt, WebSocketService.kt):
- Persist device_token in EncryptedSharedPreferences alongside device_id
- Send device_token in device:register on reconnect and playlist refresh
- Save/overwrite token from device:registered response (handles legacy
  devices getting their first token)
- Handle device:auth-error by clearing credentials and showing pairing screen
- clearDeviceCredentials() method wipes device_id, device_token, is_paired

Web player (player/index.html):
- Save deviceToken in localStorage config from device:registered response
- Send device_token in register() payload on reconnect
- Handle device:auth-error and device:unpaired events — clear config and
  show re-pair UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-11 22:52:52 -05:00
parent afbe113acf
commit 1d253c4cae
3 changed files with 60 additions and 3 deletions

View file

@ -33,6 +33,10 @@ class ServerConfig(context: Context) {
get() = prefs.getString("device_id", "") ?: "" get() = prefs.getString("device_id", "") ?: ""
set(value) = prefs.edit().putString("device_id", value).apply() set(value) = prefs.edit().putString("device_id", value).apply()
var deviceToken: String
get() = prefs.getString("device_token", "") ?: ""
set(value) = prefs.edit().putString("device_token", value).apply()
var deviceName: String var deviceName: String
get() = prefs.getString("device_name", "Unnamed Display") ?: "Unnamed Display" get() = prefs.getString("device_name", "Unnamed Display") ?: "Unnamed Display"
set(value) = prefs.edit().putString("device_name", value).apply() set(value) = prefs.edit().putString("device_name", value).apply()
@ -47,6 +51,14 @@ class ServerConfig(context: Context) {
prefs.edit().putBoolean("is_paired", paired).apply() prefs.edit().putBoolean("is_paired", paired).apply()
} }
fun clearDeviceCredentials() {
prefs.edit()
.remove("device_id")
.remove("device_token")
.remove("is_paired")
.apply()
}
fun clear() { fun clear() {
prefs.edit().clear().apply() prefs.edit().clear().apply()
} }

View file

@ -102,15 +102,25 @@ class WebSocketService : Service() {
val data = args[0] as JSONObject val data = args[0] as JSONObject
val newDeviceId = data.getString("device_id") val newDeviceId = data.getString("device_id")
config.deviceId = newDeviceId config.deviceId = newDeviceId
// Persist device_token (issued on first register, or refreshed on reconnect)
if (data.has("device_token")) {
config.deviceToken = data.getString("device_token")
}
Log.i("WebSocketService", "Registered as: $newDeviceId") Log.i("WebSocketService", "Registered as: $newDeviceId")
handler.post { onRegistered?.invoke(newDeviceId) } handler.post { onRegistered?.invoke(newDeviceId) }
startHeartbeat() startHeartbeat()
} }
on("device:unpaired") { on("device:unpaired") {
Log.w("WebSocketService", "Device not found on server - clearing config") Log.w("WebSocketService", "Device not found on server - clearing credentials")
config.setPaired(false) config.clearDeviceCredentials()
config.deviceId = "" handler.post { onUnpaired?.invoke() }
}
on("device:auth-error") { args ->
val msg = (args.firstOrNull() as? JSONObject)?.optString("error", "Authentication failed") ?: "Authentication failed"
Log.w("WebSocketService", "Device auth rejected: $msg — clearing credentials for re-pair")
config.clearDeviceCredentials()
handler.post { onUnpaired?.invoke() } handler.post { onUnpaired?.invoke() }
} }
@ -234,6 +244,11 @@ class WebSocketService : Service() {
val data = JSONObject().apply { val data = JSONObject().apply {
if (config.isProvisioned && config.isPaired) { if (config.isProvisioned && config.isPaired) {
put("device_id", config.deviceId) put("device_id", config.deviceId)
// Send device_token for authentication (may be empty for legacy devices)
val token = config.deviceToken
if (token.isNotEmpty()) {
put("device_token", token)
}
} else { } else {
// Generate a pairing code if we don't have one // Generate a pairing code if we don't have one
val pairingCode = (100000..999999).random().toString() val pairingCode = (100000..999999).random().toString()
@ -279,6 +294,10 @@ class WebSocketService : Service() {
// Re-register triggers the server to send current playlist // Re-register triggers the server to send current playlist
val data = org.json.JSONObject().apply { val data = org.json.JSONObject().apply {
put("device_id", config.deviceId) put("device_id", config.deviceId)
val token = config.deviceToken
if (token.isNotEmpty()) {
put("device_token", token)
}
put("device_info", deviceInfo.getDeviceInfo()) put("device_info", deviceInfo.getDeviceInfo())
} }
socket?.emit("device:register", data) socket?.emit("device:register", data)

View file

@ -253,6 +253,7 @@
socket.on('device:registered', (data) => { socket.on('device:registered', (data) => {
config.deviceId = data.device_id; config.deviceId = data.device_id;
if (data.device_token) config.deviceToken = data.device_token;
saveConfig(config); saveConfig(config);
console.log('Registered:', data.device_id); console.log('Registered:', data.device_id);
@ -278,6 +279,30 @@
showStatus('Waiting for content...'); showStatus('Waiting for content...');
}); });
socket.on('device:unpaired', () => {
console.warn('Device not found on server — clearing credentials');
delete config.deviceId;
delete config.deviceToken;
config.paired = false;
saveConfig(config);
document.getElementById('setupScreen').style.display = 'flex';
document.getElementById('urlForm').style.display = 'block';
document.getElementById('pairingSection').style.display = 'none';
document.getElementById('setupStatus').textContent = 'Device was removed from server. Please reconnect.';
});
socket.on('device:auth-error', (data) => {
console.warn('Device auth rejected:', data?.error || 'unknown');
delete config.deviceId;
delete config.deviceToken;
config.paired = false;
saveConfig(config);
document.getElementById('setupScreen').style.display = 'flex';
document.getElementById('urlForm').style.display = 'block';
document.getElementById('pairingSection').style.display = 'none';
document.getElementById('setupStatus').textContent = 'Authentication failed. Please re-pair this device.';
});
socket.on('device:playlist-update', (data) => { socket.on('device:playlist-update', (data) => {
console.log('Playlist update:', data.assignments?.length, 'items'); console.log('Playlist update:', data.assignments?.length, 'items');
handlePlaylistUpdate(data); handlePlaylistUpdate(data);
@ -361,6 +386,7 @@
const data = {}; const data = {};
if (config.deviceId && config.paired) { if (config.deviceId && config.paired) {
data.device_id = config.deviceId; data.device_id = config.deviceId;
if (config.deviceToken) data.device_token = config.deviceToken;
} else { } else {
const code = String(Math.floor(100000 + Math.random() * 900000)); const code = String(Math.floor(100000 + Math.random() * 900000));
config.pairingCode = code; config.pairingCode = code;