4 Commits

Author SHA1 Message Date
restitux 786579a7d8 frontend: attach auth credentials to all API requests
Add Authorization Bearer header to all fetch calls (apps, stream
start). Handle 401 responses by clearing token and redirecting to
login. Pass stream_token from the stream start response through to
the WebTransport URL as a query parameter for proxy authentication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:36:29 +00:00
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 89 additions and 62 deletions
+1
View File
@@ -25,6 +25,7 @@
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');
+11 -1
View File
@@ -1,3 +1,5 @@
import { getToken, handleUnauthorized } from './stores/authStore.svelte';
export interface App {
title: string;
id: number;
@@ -12,7 +14,15 @@ export interface AppsResponse {
export async function fetchApps() {
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);
const data = (await response.json()) as AppsResponse;
console.log(data);
+16 -5
View File
@@ -1,8 +1,11 @@
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> {
@@ -34,10 +37,16 @@ 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()}`);
}
@@ -45,15 +54,17 @@ 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 };
let streamData: StreamData = {
Url: streamDataResp.url,
CertHash: streamDataResp.cert_hash,
Width: width,
Height: height,
StreamToken: streamDataResp.stream_token,
};
return streamData;
} catch (error) {
console.error('Error getting stream data: ', error);
throw new Error('Failed to start stream: ' + error);
}
}
@@ -3,4 +3,5 @@ export const streamStore = $state({
CertHash: [0],
Width: 0,
Height: 0,
StreamToken: '',
});
+2 -3
View File
@@ -6,6 +6,7 @@
$: certHash = streamStore.CertHash;
$: width = streamStore.Width;
$: height = streamStore.Height;
$: streamToken = streamStore.StreamToken;
</script>
<svelte:head>
@@ -13,9 +14,7 @@
<meta name="description" content="Streaming game" />
</svelte:head>
<!--<section>
</section>-->
<Stream {url} {certHash} {width} {height} />
<Stream {url} {certHash} {width} {height} {streamToken} />
<style>
section {
+5 -2
View File
@@ -8,17 +8,20 @@
certHash: Array<number>;
width: number;
height: number;
streamToken: string;
}
let { url, certHash, width, height }: Props = $props();
let { url, certHash, width, height, streamToken }: 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(
url,
authenticatedUrl,
certHash,
width,
height,
+19 -12
View File
@@ -47,7 +47,16 @@ struct GetAppsResponse {
impl crate::backend::Backend {
#[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>> {
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 {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
description: "failed to get available apps".to_string(),
@@ -146,18 +155,16 @@ impl crate::backend::Backend {
}
// Filter apps by user permissions (admins see everything)
if let Some(ref user) = user {
if !user.is_admin {
let permissions = self.db.get_permissions(&user.id).unwrap_or_default();
for (server_name, apps) in get_apps_resp.apps.iter_mut() {
apps.retain(|app| {
permissions.iter().any(|p| {
p.server == *server_name && p.app_id == app.id as i64
})
});
}
get_apps_resp.apps.retain(|_, apps| !apps.is_empty());
if !user.is_admin {
let permissions = self.db.get_permissions(&user.id).unwrap_or_default();
for (server_name, apps) in get_apps_resp.apps.iter_mut() {
apps.retain(|app| {
permissions.iter().any(|p| {
p.server == *server_name && p.app_id == app.id as i64
})
});
}
get_apps_resp.apps.retain(|_, apps| !apps.is_empty());
}
Ok(Json(get_apps_resp))
@@ -85,31 +85,17 @@ impl crate::proxy::Proxy {
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 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(),
});
}
}
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,
});
}
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
@@ -85,8 +85,9 @@ pub async fn validate_stream_token(proxy: &Proxy, provided: &str) -> std::result
match token_guard.take() {
Some(expected) if expected == provided => Ok(()),
Some(_) => {
// Put the token back since it wasn't matched
// Actually no — the design is that any attempt consumes it for security
// Wrong token: still consumed by the `take()` above. Any validation
// attempt — correct or notinvalidates the token, so a wrong
// guess cannot be followed by a correct one.
Err("Invalid stream token".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
if let Some(user) = auth::get_user_from_depot(depot) {
if !user.is_admin {
match self.db.check_app_permission(&user.id, &body.server, body.id as i64) {
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;
}
let user = match auth::get_user_from_depot(depot) {
Some(u) => u.clone(),
None => {
error!("post_stream_start reached without authenticated user in depot");
return Err(AppError {
status_code: StatusCode::UNAUTHORIZED,
description: "Not authenticated".to_string(),
});
}
};
if !user.is_admin {
match self.db.check_app_permission(&user.id, &body.server, body.id as i64) {
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;
}
}
}