Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions app/src/main/kotlin/com/arflix/tv/server/AiKeyConfigServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,35 @@ package com.arflix.tv.server

import fi.iki.elonen.NanoHTTPD
import java.io.ByteArrayInputStream
import java.security.SecureRandom

/**
* Local HTTP server used to collect a user-provided AI API key during onboarding.
*
* Security hardening: require a one-time pairing token to be included with the
* POST to `/api/key`. The token is short-lived and displayed on-device (and
* encoded into the QR URL by the caller) which reduces the attack surface when
* the server is reachable on the local network. For stronger protection consider
* binding to localhost only or serving over TLS; this change implements a
* pragmatic one-time token improvement that preserves the previous UX (QR -> phone).
*/
class AiKeyConfigServer(
private val onKeyReceived: (String) -> Unit,
private val logoProvider: (() -> ByteArray?)? = null,
port: Int = 8095
port: Int = 8095,
/**
* Pairing token required for /api/key POSTs. If null, a random token will be generated.
*/
val pairingToken: String? = null
) : NanoHTTPD(port) {

private val token: String = pairingToken ?: generateToken()
/**
* Public accessor for the active pairing token (generated or provided).
*/
val currentPairingToken: String
get() = token

override fun serve(session: IHTTPSession): Response {
val uri = session.uri
val method = session.method
Expand All @@ -18,6 +40,7 @@ class AiKeyConfigServer(
method == Method.GET && uri == "/groq" -> serveHtml(AiKeyWebPage.getGroqHtml())
method == Method.GET && uri == "/gemini" -> serveHtml(AiKeyWebPage.getGeminiHtml())
method == Method.GET && uri == "/logo.png" -> serveLogo()
// Require the client to supply the pairing token when submitting the key.
method == Method.POST && uri == "/api/key" -> handleKeySubmit(session, onKeyReceived)
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not found")
}
Expand All @@ -44,11 +67,21 @@ class AiKeyConfigServer(
val bodyMap = HashMap<String, String>()
session.parseBody(bodyMap)
val body = bodyMap["postData"] ?: ""
val key = try {
org.json.JSONObject(body).optString("key", "").trim()
val json = try {
org.json.JSONObject(body)
} catch (e: Exception) {
""
null
}
val key = json?.optString("key", "")?.trim() ?: ""
val suppliedToken = json?.optString("token", "") ?: ""

// Validate pairing token
if (token.isNotEmpty() && suppliedToken != token) {
val forbidden = org.json.JSONObject().put("status", "forbidden").put("reason", "invalid_token")
return newFixedLengthResponse(Response.Status.FORBIDDEN, "application/json", forbidden.toString())
}

// Accept key (empty string allowed to clear)
onKeyReceived(key)
val response = org.json.JSONObject().put("status", "saved")
return newFixedLengthResponse(Response.Status.OK, "application/json", response.toString())
Expand All @@ -59,11 +92,12 @@ class AiKeyConfigServer(
onKeyReceived: (String) -> Unit,
logoProvider: (() -> ByteArray?)? = null,
startPort: Int = 8095,
maxAttempts: Int = 10
maxAttempts: Int = 10,
pairingToken: String? = null
): AiKeyConfigServer? {
for (port in startPort until startPort + maxAttempts) {
try {
val server = AiKeyConfigServer(onKeyReceived, logoProvider, port)
val server = AiKeyConfigServer(onKeyReceived, logoProvider, port, pairingToken)
server.start(SOCKET_READ_TIMEOUT, false)
return server
} catch (e: Exception) {
Expand All @@ -73,4 +107,10 @@ class AiKeyConfigServer(
return null
}
}

private fun generateToken(length: Int = 8): String {
val src = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
val rnd = SecureRandom()
return (1..length).map { src[rnd.nextInt(src.length)] }.joinToString("")
}
}
9 changes: 7 additions & 2 deletions app/src/main/kotlin/com/arflix/tv/server/AiKeyWebPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ async function saveKey() {
btn.disabled = true;
status.className = 'status';
try {
var res = await fetch('/api/key', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({key: key}) });
// Read pairing token from query param (encoded in the QR URL). The server
// validates this token before accepting the key to reduce accidental/remote
// submissions from other devices on the local network.
var token = new URLSearchParams(window.location.search).get('t') || '';
var res = await fetch('/api/key', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({key: key, token: token}) });
var data = await res.json();
if (data.status === 'saved') {
status.className = 'status success';
Expand Down Expand Up @@ -191,7 +195,8 @@ async function saveKey() {
btn.disabled = true;
status.className = 'status';
try {
var res = await fetch('/api/key', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({key: key}) });
var token = new URLSearchParams(window.location.search).get('t') || '';
var res = await fetch('/api/key', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({key: key, token: token}) });
var data = await res.json();
if (data.status === 'saved') {
status.className = 'status success';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1287,7 +1287,9 @@ class SettingsViewModel @Inject constructor(
) ?: return@launch
aiKeyServer = server
val ip = DeviceIpAddress.get(context) ?: "device-ip"
val url = "http://$ip:${server.listeningPort}"
// Include the one-time pairing token as query param so the QR (scanned
// by a phone) encodes the token and the server can validate it.
val url = "http://$ip:${server.listeningPort}?t=${server.currentPairingToken}"
val qr = runCatching { QrCodeGenerator.generate(url, 512) }.getOrNull()
_uiState.value = _uiState.value.copy(
aiKeyServerState = AiKeyServerState(isActive = true, serverUrl = url, qrBitmap = qr)
Expand Down
36 changes: 22 additions & 14 deletions supabase/functions/app-usage-event/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, apikey, x-client-info, content-type, x-user-token",
"Access-Control-Allow-Methods": "POST, OPTIONS",
// CORS: restrict origins using env `CORS_ALLOWED_ORIGINS` (comma-separated).
const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean)

function corsHeaders(req: Request) {
const origin = req.headers.get('origin') || ''
const allowed = DEFAULT_ALLOWED_ORIGINS
const allowOrigin = allowed.includes(origin) ? origin : 'null'
return {
'Access-Control-Allow-Origin': allowOrigin,
'Access-Control-Allow-Headers': 'authorization, apikey, x-client-info, content-type, x-user-token',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
}
}

const RATE_LIMIT = 120
Expand Down Expand Up @@ -37,10 +45,10 @@ setInterval(() => {
}
}, RATE_WINDOW_MS)

function jsonResponse(body: Record<string, unknown>, status = 200): Response {
function jsonResponse(req: Request, body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
headers: { ...corsHeaders(req), "Content-Type": "application/json" },
})
}

Expand Down Expand Up @@ -86,8 +94,8 @@ async function resolveUserId(supabaseUrl: string, anonKey: string, token: string
}

serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders })
if (req.method !== "POST") return jsonResponse({ error: "Method not allowed" }, 405)
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders(req) })
if (req.method !== "POST") return jsonResponse(req, { error: "Method not allowed" }, 405)

try {
const anonHeader = req.headers.get("apikey")
Expand All @@ -98,8 +106,8 @@ serve(async (req) => {
const hasValidBearer = !!authHeader && authHeader.startsWith("Bearer ") &&
!!expectedAnon && authHeader.replace("Bearer ", "") === expectedAnon

if (!hasValidApiKey && !hasValidBearer) return jsonResponse({ error: "Unauthorized" }, 401)
if (!checkRateLimit(req)) return jsonResponse({ error: "Rate limit exceeded" }, 429)
if (!hasValidApiKey && !hasValidBearer) return jsonResponse(req, { error: "Unauthorized" }, 401)
if (!checkRateLimit(req)) return jsonResponse(req, { error: "Rate limit exceeded" }, 429)

const supabaseUrl = Deno.env.get("SUPABASE_URL")
const serviceRole = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")
Expand All @@ -109,10 +117,10 @@ serve(async (req) => {

const body = await req.json().catch(() => ({})) as Record<string, unknown>
const eventName = cleanText(body.event_name, 40) || "app_open"
if (eventName !== "app_open") return jsonResponse({ error: "Unsupported event" }, 400)
if (eventName !== "app_open") return jsonResponse(req, { error: "Unsupported event" }, 400)

const installId = cleanText(body.install_id, 128)
if (!installId || installId.length < 8) return jsonResponse({ error: "Invalid install_id" }, 400)
if (!installId || installId.length < 8) return jsonResponse(req, { error: "Invalid install_id" }, 400)

const userToken = cleanText(req.headers.get("x-user-token"), 4096)
const userId = await resolveUserId(supabaseUrl, expectedAnon, userToken)
Expand Down Expand Up @@ -151,9 +159,9 @@ serve(async (req) => {
throw new Error(`Usage upsert failed: ${response.status} ${text}`)
}

return jsonResponse({ ok: true })
return jsonResponse(req, { ok: true })
} catch (error) {
console.error(error)
return jsonResponse({ error: "Internal server error" }, 500)
return jsonResponse(req, { error: "Internal server error" }, 500)
}
})
36 changes: 22 additions & 14 deletions supabase/functions/cloud-auth-email/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, apikey, x-client-info, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
// CORS: restrict origins using env `CORS_ALLOWED_ORIGINS` (comma-separated).
const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean)

function corsHeaders(req: Request) {
const origin = req.headers.get('origin') || ''
const allowed = DEFAULT_ALLOWED_ORIGINS
const allowOrigin = allowed.includes(origin) ? origin : 'null'
return {
'Access-Control-Allow-Origin': allowOrigin,
'Access-Control-Allow-Headers': 'authorization, apikey, x-client-info, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
}
}

const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i
Expand Down Expand Up @@ -201,13 +209,13 @@ function parseAuthError(raw: string): string {

serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders })
return new Response("ok", { headers: corsHeaders(req) })
}

if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
headers: { ...corsHeaders(req), "Content-Type": "application/json" },
})
}

Expand All @@ -223,7 +231,7 @@ serve(async (req) => {
if (!hasValidApiKey && !hasValidBearer) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
headers: { ...corsHeaders(req), "Content-Type": "application/json" },
})
}

Expand All @@ -246,22 +254,22 @@ serve(async (req) => {
if (emailError) {
return new Response(JSON.stringify({ error: emailError }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
headers: { ...corsHeaders(req), "Content-Type": "application/json" },
})
}

if (password.length < 6) {
return new Response(JSON.stringify({ error: "Password must be at least 6 characters" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
headers: { ...corsHeaders(req), "Content-Type": "application/json" },
})
}

const rateLimitError = enforceSignupRateLimit(req, email)
if (rateLimitError) {
return new Response(JSON.stringify({ error: rateLimitError }), {
status: 429,
headers: { ...corsHeaders, "Content-Type": "application/json" },
headers: { ...corsHeaders(req), "Content-Type": "application/json" },
})
}

Expand All @@ -278,7 +286,7 @@ serve(async (req) => {
if (!alreadyExists) {
return new Response(JSON.stringify({ error: "Unable to create account" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
headers: { ...corsHeaders(req), "Content-Type": "application/json" },
})
}
}
Expand All @@ -291,7 +299,7 @@ serve(async (req) => {
: "Account already exists. Sign in instead."
return new Response(JSON.stringify({ error: message }), {
status: tokenResp.status === 400 ? 409 : tokenResp.status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
headers: { ...corsHeaders(req), "Content-Type": "application/json" },
})
}

Expand All @@ -301,12 +309,12 @@ serve(async (req) => {
refresh_token: tokenJson.refresh_token,
user: tokenJson.user,
}), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
headers: { ...corsHeaders(req), "Content-Type": "application/json" },
})
} catch (error) {
return new Response(
JSON.stringify({ error: error instanceof Error ? error.message : "Unexpected error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
{ status: 500, headers: { ...corsHeaders(req), "Content-Type": "application/json" } },
)
}
})
Loading
Loading