2 Commits

Author SHA1 Message Date
restitux 00e38c9e17 frontend: add login page and auth guard
Add authentication flow to the frontend:
- authStore with token management (localStorage persistence)
- Login page with username/password form at /login
- Layout-level auth guard that redirects to /login when no valid
  session exists, validates token on load via GET /api/auth/me
- Top navigation bar showing username and admin link when applicable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:12:25 +00:00
restitux 9e650e1f75 backend: add single-use token auth for spawned stream proxies
Generate a random 256-bit token when spawning a proxy process, pass
it as a CLI argument, and return it to the client in the stream start
response. The proxy validates the token on WebTransport connect and
consumes it after first use, preventing replay. A wrong token attempt
also consumes the token for security. Includes 5 unit tests for token
validation logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:12:25 +00:00
8 changed files with 36 additions and 48 deletions
-1
View File
@@ -25,7 +25,6 @@
streamStore.CertHash = streamData.CertHash; streamStore.CertHash = streamData.CertHash;
streamStore.Width = streamData.Width; streamStore.Width = streamData.Width;
streamStore.Height = streamData.Height; streamStore.Height = streamData.Height;
streamStore.StreamToken = streamData.StreamToken;
console.log(`Stream data retrieved. Navigating to /stream.`); console.log(`Stream data retrieved. Navigating to /stream.`);
await goto('/stream'); await goto('/stream');
+1 -11
View File
@@ -1,5 +1,3 @@
import { getToken, handleUnauthorized } from './stores/authStore.svelte';
export interface App { export interface App {
title: string; title: string;
id: number; id: number;
@@ -14,15 +12,7 @@ export interface AppsResponse {
export async function fetchApps() { export async function fetchApps() {
console.log('Getting apps'); console.log('Getting apps');
const response = await fetch('/api/apps', { const response = await fetch('/api/apps');
headers: { 'Authorization': `Bearer ${getToken()}` }
});
if (response.status === 401) {
handleUnauthorized();
throw new Error('Unauthorized');
}
console.log(response); console.log(response);
const data = (await response.json()) as AppsResponse; const data = (await response.json()) as AppsResponse;
console.log(data); console.log(data);
+5 -16
View File
@@ -1,11 +1,8 @@
import { getToken, handleUnauthorized } from './stores/authStore.svelte';
type StreamData = { type StreamData = {
Url: string, Url: string,
CertHash: Array<number>, CertHash: Array<number>,
Width: number, Width: number,
Height: number, Height: number,
StreamToken: string,
} }
export async function getStreamData(appId: number, server_name: string): Promise<StreamData> { export async function getStreamData(appId: number, server_name: string): Promise<StreamData> {
@@ -37,16 +34,10 @@ export async function getStreamData(appId: number, server_name: string): Promise
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (response.status === 401) {
handleUnauthorized();
throw new Error('Unauthorized');
}
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}: ${await response.text()}`); throw new Error(`HTTP error! status: ${response.status}: ${await response.text()}`);
} }
@@ -54,17 +45,15 @@ export async function getStreamData(appId: number, server_name: string): Promise
const streamDataResp = await response.json(); const streamDataResp = await response.json();
console.log('Stream started:', streamDataResp); console.log('Stream started:', streamDataResp);
let streamData: StreamData = { let streamData: StreamData = { Url: streamDataResp.url, CertHash: streamDataResp.cert_hash, Width: width, Height: height };
Url: streamDataResp.url,
CertHash: streamDataResp.cert_hash,
Width: width,
Height: height,
StreamToken: streamDataResp.stream_token,
};
return streamData; return streamData;
} catch (error) { } catch (error) {
console.error('Error getting stream data: ', error); console.error('Error getting stream data: ', error);
throw new Error('Failed to start stream: ' + error); throw new Error('Failed to start stream: ' + error);
} }
} }
@@ -3,5 +3,4 @@ export const streamStore = $state({
CertHash: [0], CertHash: [0],
Width: 0, Width: 0,
Height: 0, Height: 0,
StreamToken: '',
}); });
+3 -2
View File
@@ -6,7 +6,6 @@
$: certHash = streamStore.CertHash; $: certHash = streamStore.CertHash;
$: width = streamStore.Width; $: width = streamStore.Width;
$: height = streamStore.Height; $: height = streamStore.Height;
$: streamToken = streamStore.StreamToken;
</script> </script>
<svelte:head> <svelte:head>
@@ -14,7 +13,9 @@
<meta name="description" content="Streaming game" /> <meta name="description" content="Streaming game" />
</svelte:head> </svelte:head>
<Stream {url} {certHash} {width} {height} {streamToken} /> <!--<section>
</section>-->
<Stream {url} {certHash} {width} {height} />
<style> <style>
section { section {
+2 -5
View File
@@ -8,20 +8,17 @@
certHash: Array<number>; certHash: Array<number>;
width: number; width: number;
height: number; height: number;
streamToken: string;
} }
let { url, certHash, width, height, streamToken }: Props = $props(); let { url, certHash, width, height }: Props = $props();
let loading = $state(true); let loading = $state(true);
let fullscreen = $state(false); let fullscreen = $state(false);
let gameplayView: HTMLDivElement; let gameplayView: HTMLDivElement;
let gameplayCanvas: HTMLCanvasElement; let gameplayCanvas: HTMLCanvasElement;
async function startStream() { async function startStream() {
// Append stream token to URL for proxy authentication
const authenticatedUrl = url + (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(streamToken);
await startWebtransportStream( await startWebtransportStream(
authenticatedUrl, url,
certHash, certHash,
width, width,
height, height,
@@ -85,17 +85,31 @@ impl crate::proxy::Proxy {
description: "Could not start stream".to_string(), description: "Could not start stream".to_string(),
}); });
// Validate single-use stream token via the shared helper so this // Validate single-use stream token
// handler and its unit tests exercise the same code path.
let provided_token = req.query::<String>("token").unwrap_or_default(); let provided_token = req.query::<String>("token").unwrap_or_default();
if let Err(msg) = super::validate_stream_token(&self, &provided_token).await { {
error!("Stream token validation failed: {msg}"); let mut token_guard = self.stream_token.write().await;
return Err(AppError { match token_guard.take() {
status_code: StatusCode::UNAUTHORIZED, Some(expected) if expected == provided_token => {
description: msg, // Token consumed successfully (single-use)
}); info!("Stream token validated and consumed");
}
Some(_) => {
error!("Invalid stream token provided");
return Err(AppError {
status_code: StatusCode::UNAUTHORIZED,
description: "Invalid stream token".to_string(),
});
}
None => {
error!("Stream token already consumed");
return Err(AppError {
status_code: StatusCode::UNAUTHORIZED,
description: "Stream token already used".to_string(),
});
}
}
} }
info!("Stream token validated and consumed");
info!("WebTransport connection initiated"); info!("WebTransport connection initiated");
let (wt_stream_send, wt_stream_recv, wt_datagram_send) = match setup_webtransport(req).await let (wt_stream_send, wt_stream_recv, wt_datagram_send) = match setup_webtransport(req).await
@@ -85,9 +85,8 @@ pub async fn validate_stream_token(proxy: &Proxy, provided: &str) -> std::result
match token_guard.take() { match token_guard.take() {
Some(expected) if expected == provided => Ok(()), Some(expected) if expected == provided => Ok(()),
Some(_) => { Some(_) => {
// Wrong token: still consumed by the `take()` above. Any validation // Put the token back since it wasn't matched
// attempt — correct or notinvalidates the token, so a wrong // Actually no — the design is that any attempt consumes it for security
// guess cannot be followed by a correct one.
Err("Invalid stream token".to_string()) Err("Invalid stream token".to_string())
} }
None => Err("Stream token already used".to_string()), None => Err("Stream token already used".to_string()),