feat: added auth by api key
Some checks failed
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m15s
Publish Web Player Image / build-and-push-image (push) Successful in 1m5s
Publish Server Image / build-and-push-image (push) Failing after 12m24s

This commit is contained in:
Boris Cherepanov
2026-03-23 12:07:57 +03:00
parent c30a3aff5d
commit 03f95cfd05
4 changed files with 46 additions and 19 deletions

View File

@@ -53,6 +53,7 @@ services:
FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}" FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
FURUMI_PLAYER_STORAGE_DIR: "/storage" FURUMI_PLAYER_STORAGE_DIR: "/storage"
FURUMI_PLAYER_BIND: "0.0.0.0:8085" FURUMI_PLAYER_BIND: "0.0.0.0:8085"
FURUMI_PLAYER_API_KEY: "node-player-api-key"
volumes: volumes:
- ./storage:/storage - ./storage:/storage
restart: always restart: always

View File

@@ -39,6 +39,10 @@ struct Args {
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided. /// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")] #[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
oidc_session_secret: Option<String>, oidc_session_secret: Option<String>,
/// API key for x-api-key header auth (alternative to OIDC session)
#[arg(long, env = "FURUMI_PLAYER_API_KEY")]
api_key: Option<String>,
} }
#[tokio::main] #[tokio::main]
@@ -90,10 +94,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1); std::process::exit(1);
}); });
if args.api_key.is_some() {
tracing::info!("x-api-key auth: enabled");
}
let state = Arc::new(web::AppState { let state = Arc::new(web::AppState {
pool, pool,
storage_dir: Arc::new(args.storage_dir), storage_dir: Arc::new(args.storage_dir),
oidc: oidc_state, oidc: oidc_state,
api_key: args.api_key,
}); });
tracing::info!("Web player: http://{}", bind_addr); tracing::info!("Web player: http://{}", bind_addr);

View File

@@ -5,6 +5,8 @@ use axum::{
middleware::Next, middleware::Next,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
const X_API_KEY: &str = "x-api-key";
use openidconnect::{ use openidconnect::{
core::{CoreClient, CoreProviderMetadata, CoreResponseType}, core::{CoreClient, CoreProviderMetadata, CoreResponseType},
reqwest::async_http_client, reqwest::async_http_client,
@@ -92,37 +94,51 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
} }
} }
/// Auth middleware: requires valid SSO session cookie. /// Auth middleware: requires valid SSO session cookie or x-api-key header.
pub async fn require_auth( pub async fn require_auth(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
req: Request, req: Request,
next: Next, next: Next,
) -> Response { ) -> Response {
let oidc = match &state.oidc { // 1. Check x-api-key header (if configured)
Some(o) => o, if let Some(ref expected) = state.api_key {
None => return next.run(req).await, // No OIDC configured = no auth if let Some(val) = req
}; .headers()
.get(X_API_KEY)
let cookies = req .and_then(|v| v.to_str().ok())
.headers() {
.get(header::COOKIE) if val == expected {
.and_then(|v| v.to_str().ok())
.unwrap_or("");
for c in cookies.split(';') {
let c = c.trim();
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
return next.run(req).await; return next.run(req).await;
} }
} }
} }
// 2. Check SSO session cookie (if OIDC configured)
if let Some(ref oidc) = state.oidc {
let cookies = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
for c in cookies.split(';') {
let c = c.trim();
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
return next.run(req).await;
}
}
}
}
let uri = req.uri().to_string(); let uri = req.uri().to_string();
if uri.starts_with("/api/") { if uri.starts_with("/api/") {
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response() (StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
} else { } else if state.oidc.is_some() {
Redirect::to("/login").into_response() Redirect::to("/login").into_response()
} else {
// Only API key configured — no web login available
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
} }
} }

View File

@@ -13,6 +13,7 @@ pub struct AppState {
#[allow(dead_code)] #[allow(dead_code)]
pub storage_dir: Arc<PathBuf>, pub storage_dir: Arc<PathBuf>,
pub oidc: Option<Arc<auth::OidcState>>, pub oidc: Option<Arc<auth::OidcState>>,
pub api_key: Option<String>,
} }
pub fn build_router(state: Arc<AppState>) -> Router { pub fn build_router(state: Arc<AppState>) -> Router {
@@ -32,9 +33,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/", get(player_html)) .route("/", get(player_html))
.nest("/api", library); .nest("/api", library);
let has_oidc = state.oidc.is_some(); let requires_auth = state.oidc.is_some();
let app = if has_oidc { let app = if requires_auth {
authed authed
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)) .route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
} else { } else {