use axum::{ body::Body, extract::{Request, State}, http::{HeaderMap, StatusCode, header}, middleware::Next, response::{Html, IntoResponse, Redirect, Response}, Form, }; use sha2::{Digest, Sha256}; use serde::Deserialize; use base64::Engine; use hmac::{Hmac, Mac}; use rand::RngCore; use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType}; use openidconnect::reqwest::async_http_client; use openidconnect::{ AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, }; use super::{OidcState, WebState}; /// Cookie name used to store the session token. const SESSION_COOKIE: &str = "furumi_session"; /// Compute SHA-256 of the token as hex string (stored in cookie, not raw token). pub fn token_hash(token: &str) -> String { let mut h = Sha256::new(); h.update(token.as_bytes()); format!("{:x}", h.finalize()) } /// axum middleware: if token is configured, requires a valid session cookie. pub async fn require_auth( State(state): State, req: Request, next: Next, ) -> Response { // Auth disabled when token is empty if state.token.is_empty() { return next.run(req).await; } let cookies = req .headers() .get(header::COOKIE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); let expected = token_hash(&state.token); let mut authed = false; for c in cookies.split(';') { let c = c.trim(); if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) { if val == expected { authed = true; break; } else if let Some(oidc) = &state.oidc { if verify_sso_cookie(&oidc.session_secret, val) { authed = true; break; } } } } if authed { next.run(req).await } else { let uri = req.uri().path(); if uri.starts_with("/api/") { (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() } else { Redirect::to("/login").into_response() } } } type HmacSha256 = Hmac; pub fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String { let mut mac = HmacSha256::new_from_slice(secret).unwrap(); mac.update(user_id.as_bytes()); let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); format!("sso:{}:{}", user_id, sig) } pub fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> bool { let parts: Vec<&str> = cookie_val.split(':').collect(); if parts.len() != 3 || parts[0] != "sso" { return false; } let user_id = parts[1]; let sig = parts[2]; let mut mac = HmacSha256::new_from_slice(secret).unwrap(); mac.update(user_id.as_bytes()); let expected_sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); sig == expected_sig } /// GET /login — show login form. pub async fn login_page(State(state): State) -> impl IntoResponse { let token_enabled = !state.token.is_empty(); let oidc_enabled = state.oidc.is_some(); if !token_enabled && !oidc_enabled { return Redirect::to("/").into_response(); } let html = LOGIN_HTML.replace( "", if oidc_enabled { r#"
OR
Log in with Authentik (SSO)"# } else { "" } ); Html(html).into_response() } #[derive(Deserialize)] pub struct LoginForm { password: String, } /// POST /login — validate password, set session cookie. pub async fn login_submit( State(state): State, Form(form): Form, ) -> impl IntoResponse { if state.token.is_empty() { return Redirect::to("/").into_response(); } if form.password == *state.token { let hash = token_hash(&state.token); let cookie = format!( "{}={}; HttpOnly; SameSite=Strict; Path=/", SESSION_COOKIE, hash ); let mut headers = HeaderMap::new(); headers.insert(header::SET_COOKIE, cookie.parse().unwrap()); headers.insert(header::LOCATION, "/".parse().unwrap()); (StatusCode::FOUND, headers, Body::empty()).into_response() } else { Html(LOGIN_ERROR_HTML).into_response() } } /// GET /logout — clear session cookie and redirect to login. pub async fn logout() -> impl IntoResponse { let cookie = format!( "{}=; HttpOnly; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT", SESSION_COOKIE ); let mut headers = HeaderMap::new(); headers.insert(header::SET_COOKIE, cookie.parse().unwrap()); headers.insert(header::LOCATION, "/login".parse().unwrap()); (StatusCode::FOUND, headers, Body::empty()).into_response() } pub async fn oidc_init( issuer: String, client_id: String, client_secret: String, redirect: String, ) -> anyhow::Result { let provider_metadata = CoreProviderMetadata::discover_async( IssuerUrl::new(issuer)?, async_http_client, ) .await?; let client = CoreClient::from_provider_metadata( provider_metadata, ClientId::new(client_id), Some(ClientSecret::new(client_secret)), ) .set_redirect_uri(RedirectUrl::new(redirect)?); let mut session_secret = vec![0u8; 32]; rand::thread_rng().fill_bytes(&mut session_secret); Ok(OidcState { client, session_secret, }) } pub async fn oidc_login( State(state): State, req: Request, ) -> impl IntoResponse { let oidc = match &state.oidc { Some(o) => o, None => return Redirect::to("/login").into_response(), }; let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); let (auth_url, csrf_token, nonce) = oidc .client .authorize_url( AuthenticationFlow::::AuthorizationCode, CsrfToken::new_random, Nonce::new_random, ) .add_scope(Scope::new("openid".to_string())) .add_scope(Scope::new("profile".to_string())) .set_pkce_challenge(pkce_challenge) .url(); let cookie_val = format!("{}:{}:{}", csrf_token.secret(), nonce.secret(), pkce_verifier.secret()); // Determine if we are running behind an HTTPS proxy let is_https = req.headers().get("x-forwarded-proto") .and_then(|v| v.to_str().ok()) .map(|s| s == "https") .unwrap_or(false); // If HTTPS, use SameSite=None + Secure to fully support cross-domain POST redirects. // Otherwise fallback to Lax for local testing. let cookie_attrs = if is_https { "SameSite=None; Secure" } else { "SameSite=Lax" }; let cookie = format!("furumi_oidc_state={}; HttpOnly; {}; Path=/; Max-Age=3600", cookie_val, cookie_attrs); let mut headers = HeaderMap::new(); headers.insert(header::SET_COOKIE, cookie.parse().unwrap()); headers.insert(header::LOCATION, auth_url.as_str().parse().unwrap()); headers.insert(header::CACHE_CONTROL, "no-store, no-cache, must-revalidate".parse().unwrap()); (StatusCode::FOUND, headers, Body::empty()).into_response() } #[derive(Deserialize)] pub struct AuthCallbackQuery { code: String, state: String, } pub async fn oidc_callback( State(state): State, axum::extract::Query(query): axum::extract::Query, req: Request, ) -> impl IntoResponse { let oidc = match &state.oidc { Some(o) => o, None => return Redirect::to("/login").into_response(), }; let cookies = req .headers() .get(header::COOKIE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); let mut matching_val = None; for c in cookies.split(';') { let c = c.trim(); if let Some(val) = c.strip_prefix("furumi_oidc_state=") { let parts: Vec<&str> = val.split(':').collect(); if parts.len() == 3 && parts[0] == query.state { matching_val = Some(val.to_string()); break; } } } let cookie_val = match matching_val { Some(c) => c, None => { tracing::warn!("OIDC callback failed: Invalid state or missing valid cookie. Received cookies: {}", cookies); return (StatusCode::BAD_REQUEST, "Invalid state").into_response(); } }; let parts: Vec<&str> = cookie_val.split(':').collect(); let nonce = Nonce::new(parts[1].to_string()); let pkce_verifier = PkceCodeVerifier::new(parts[2].to_string()); let token_response = oidc .client .exchange_code(AuthorizationCode::new(query.code)) .set_pkce_verifier(pkce_verifier) .request_async(async_http_client) .await; let token_response = match token_response { Ok(tr) => tr, Err(e) => { tracing::error!("OIDC exchange code error: {:?}", e); if let openidconnect::RequestTokenError::ServerResponse(err) = &e { tracing::error!("OIDC Server returned error: {:?}", err); } return (StatusCode::INTERNAL_SERVER_ERROR, format!("OIDC error: {}", e)).into_response(); } }; let id_token = match token_response.id_token() { Some(t) => t, None => return (StatusCode::INTERNAL_SERVER_ERROR, "No ID token").into_response(), }; let claims = match id_token.claims(&oidc.client.id_token_verifier(), &nonce) { Ok(c) => c, Err(e) => return (StatusCode::UNAUTHORIZED, format!("Invalid ID token: {}", e)).into_response(), }; let subject = claims.subject().as_str(); let session_val = generate_sso_cookie(&oidc.session_secret, subject); let is_https = req.headers().get("x-forwarded-proto") .and_then(|v| v.to_str().ok()) .map(|s| s == "https") .unwrap_or(false); let session_attrs = if is_https { "SameSite=Strict; Secure" } else { "SameSite=Strict" }; let session_cookie = format!("{}={}; HttpOnly; {}; Path=/", SESSION_COOKIE, session_val, session_attrs); let clear_state_cookie = "furumi_oidc_state=; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT"; let mut headers = HeaderMap::new(); headers.insert(header::SET_COOKIE, session_cookie.parse().unwrap()); headers.append(header::SET_COOKIE, clear_state_cookie.parse().unwrap()); headers.insert(header::LOCATION, "/".parse().unwrap()); (StatusCode::FOUND, headers, Body::empty()).into_response() } const LOGIN_HTML: &str = r#" Furumi Player — Login
Enter access token to continue
"#; const LOGIN_ERROR_HTML: &str = r#" Furumi Player — Login
Enter access token to continue

❌ Invalid token. Please try again.

"#;