3 Commits

Author SHA1 Message Date
restitux af9359bbdf 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:36:29 +00:00
restitux b8c705554f 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:36:29 +00:00
restitux 826a3b59c9 backend: gate existing endpoints behind auth and app permissions
Move /api/pair, /api/apps, and /api/stream/start under the session
auth middleware so they require a valid session token. Add app-level
permission filtering: non-admin users only see and can stream apps
they have been explicitly granted access to. Admins bypass all
permission checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:12:22 +00:00
10 changed files with 64 additions and 87 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,
+19 -12
View File
@@ -47,7 +47,16 @@ struct GetAppsResponse {
impl crate::backend::Backend { impl crate::backend::Backend {
#[craft(endpoint(status_codes(StatusCode::OK, StatusCode::INTERNAL_SERVER_ERROR)))] #[craft(endpoint(status_codes(StatusCode::OK, StatusCode::INTERNAL_SERVER_ERROR)))]
pub async fn get_apps(self: ::std::sync::Arc<Self>, depot: &mut Depot) -> AppResult<Json<GetAppsResponse>> { pub async fn get_apps(self: ::std::sync::Arc<Self>, depot: &mut Depot) -> AppResult<Json<GetAppsResponse>> {
let user = auth::get_user_from_depot(depot).cloned(); let user = match auth::get_user_from_depot(depot) {
Some(u) => u.clone(),
None => {
error!("get_apps reached without authenticated user in depot");
return Err(AppError {
status_code: StatusCode::UNAUTHORIZED,
description: "Not authenticated".to_string(),
});
}
};
let standard_error = Err(AppError { let standard_error = Err(AppError {
status_code: StatusCode::INTERNAL_SERVER_ERROR, status_code: StatusCode::INTERNAL_SERVER_ERROR,
description: "failed to get available apps".to_string(), description: "failed to get available apps".to_string(),
@@ -146,18 +155,16 @@ impl crate::backend::Backend {
} }
// Filter apps by user permissions (admins see everything) // Filter apps by user permissions (admins see everything)
if let Some(ref user) = user { if !user.is_admin {
if !user.is_admin { let permissions = self.db.get_permissions(&user.id).unwrap_or_default();
let permissions = self.db.get_permissions(&user.id).unwrap_or_default(); for (server_name, apps) in get_apps_resp.apps.iter_mut() {
for (server_name, apps) in get_apps_resp.apps.iter_mut() { apps.retain(|app| {
apps.retain(|app| { permissions.iter().any(|p| {
permissions.iter().any(|p| { p.server == *server_name && p.app_id == app.id as i64
p.server == *server_name && p.app_id == app.id as i64 })
}) });
});
}
get_apps_resp.apps.retain(|_, apps| !apps.is_empty());
} }
get_apps_resp.apps.retain(|_, apps| !apps.is_empty());
} }
Ok(Json(get_apps_resp)) Ok(Json(get_apps_resp))
@@ -85,31 +85,17 @@ impl crate::proxy::Proxy {
description: "Could not start stream".to_string(), description: "Could not start stream".to_string(),
}); });
// Validate single-use stream token // Validate single-use stream token via the shared helper so this
// 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 {
let mut token_guard = self.stream_token.write().await; error!("Stream token validation failed: {msg}");
match token_guard.take() { return Err(AppError {
Some(expected) if expected == provided_token => { status_code: StatusCode::UNAUTHORIZED,
// Token consumed successfully (single-use) description: msg,
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,8 +85,9 @@ 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(_) => {
// Put the token back since it wasn't matched // Wrong token: still consumed by the `take()` above. Any validation
// Actually no — the design is that any attempt consumes it for security // attempt — correct or notinvalidates the token, so a wrong
// 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()),
+22 -14
View File
@@ -90,20 +90,28 @@ impl crate::backend::Backend {
}); });
// Check app permission // Check app permission
if let Some(user) = auth::get_user_from_depot(depot) { let user = match auth::get_user_from_depot(depot) {
if !user.is_admin { Some(u) => u.clone(),
match self.db.check_app_permission(&user.id, &body.server, body.id as i64) { None => {
Ok(true) => {} error!("post_stream_start reached without authenticated user in depot");
Ok(false) => { return Err(AppError {
return Err(AppError { status_code: StatusCode::UNAUTHORIZED,
status_code: StatusCode::FORBIDDEN, description: "Not authenticated".to_string(),
description: "You do not have permission to access this application".to_string(), });
}); }
} };
Err(e) => { if !user.is_admin {
error!("Permission check error: {e}"); match self.db.check_app_permission(&user.id, &body.server, body.id as i64) {
return standard_error; Ok(true) => {}
} Ok(false) => {
return Err(AppError {
status_code: StatusCode::FORBIDDEN,
description: "You do not have permission to access this application".to_string(),
});
}
Err(e) => {
error!("Permission check error: {e}");
return standard_error;
} }
} }
} }