2 Commits

Author SHA1 Message Date
restitux 917acfdb27 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:24:12 +00:00
restitux 0fd90e8935 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:24:09 +00:00
7 changed files with 34 additions and 45 deletions
-1
View File
@@ -25,7 +25,6 @@
streamStore.CertHash = streamData.CertHash;
streamStore.Width = streamData.Width;
streamStore.Height = streamData.Height;
streamStore.StreamToken = streamData.StreamToken;
console.log(`Stream data retrieved. Navigating to /stream.`);
await goto('/stream');
+1 -11
View File
@@ -1,5 +1,3 @@
import { getToken, handleUnauthorized } from './stores/authStore.svelte';
export interface App {
title: string;
id: number;
@@ -14,15 +12,7 @@ export interface AppsResponse {
export async function fetchApps() {
console.log('Getting apps');
const response = await fetch('/api/apps', {
headers: { 'Authorization': `Bearer ${getToken()}` }
});
if (response.status === 401) {
handleUnauthorized();
throw new Error('Unauthorized');
}
const response = await fetch('/api/apps');
console.log(response);
const data = (await response.json()) as AppsResponse;
console.log(data);
+5 -16
View File
@@ -1,11 +1,8 @@
import { getToken, handleUnauthorized } from './stores/authStore.svelte';
type StreamData = {
Url: string,
CertHash: Array<number>,
Width: number,
Height: number,
StreamToken: string,
}
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',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
},
body: JSON.stringify(payload)
});
if (response.status === 401) {
handleUnauthorized();
throw new Error('Unauthorized');
}
if (!response.ok) {
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();
console.log('Stream started:', streamDataResp);
let streamData: StreamData = {
Url: streamDataResp.url,
CertHash: streamDataResp.cert_hash,
Width: width,
Height: height,
StreamToken: streamDataResp.stream_token,
};
let streamData: StreamData = { Url: streamDataResp.url, CertHash: streamDataResp.cert_hash, Width: width, Height: height };
return streamData;
} catch (error) {
console.error('Error getting stream data: ', error);
throw new Error('Failed to start stream: ' + error);
}
}
@@ -3,5 +3,4 @@ export const streamStore = $state({
CertHash: [0],
Width: 0,
Height: 0,
StreamToken: '',
});
+3 -2
View File
@@ -6,7 +6,6 @@
$: certHash = streamStore.CertHash;
$: width = streamStore.Width;
$: height = streamStore.Height;
$: streamToken = streamStore.StreamToken;
</script>
<svelte:head>
@@ -14,7 +13,9 @@
<meta name="description" content="Streaming game" />
</svelte:head>
<Stream {url} {certHash} {width} {height} {streamToken} />
<!--<section>
</section>-->
<Stream {url} {certHash} {width} {height} />
<style>
section {
+2 -5
View File
@@ -8,20 +8,17 @@
certHash: Array<number>;
width: 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 fullscreen = $state(false);
let gameplayView: HTMLDivElement;
let gameplayCanvas: HTMLCanvasElement;
async function startStream() {
// Append stream token to URL for proxy authentication
const authenticatedUrl = url + (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(streamToken);
await startWebtransportStream(
authenticatedUrl,
url,
certHash,
width,
height,
@@ -85,17 +85,31 @@ impl crate::proxy::Proxy {
description: "Could not start stream".to_string(),
});
// Validate single-use stream token via the shared helper so this
// handler and its unit tests exercise the same code path.
// Validate single-use stream token
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}");
return Err(AppError {
status_code: StatusCode::UNAUTHORIZED,
description: msg,
});
{
let mut token_guard = self.stream_token.write().await;
match token_guard.take() {
Some(expected) if expected == provided_token => {
// 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");
let (wt_stream_send, wt_stream_recv, wt_datagram_send) = match setup_webtransport(req).await