4 Commits

Author SHA1 Message Date
restitux b1421f7dd5 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:12:25 +00:00
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
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
2 changed files with 41 additions and 26 deletions
+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))
+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;
}
}
}