Added oauth2 OIDC support
This commit is contained in:
1547
Cargo.lock
generated
1547
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,10 @@ sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
openidconnect = "3.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
hmac = "0.12"
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.26.0"
|
||||
|
||||
@@ -45,6 +45,22 @@ struct Args {
|
||||
/// Disable TLS encryption (not recommended, use only for debugging)
|
||||
#[arg(long, default_value_t = false)]
|
||||
no_tls: bool,
|
||||
|
||||
/// OIDC Issuer URL (e.g. https://auth.example.com/application/o/furumi/)
|
||||
#[arg(long, env = "FURUMI_OIDC_ISSUER_URL")]
|
||||
oidc_issuer_url: Option<String>,
|
||||
|
||||
/// OIDC Client ID
|
||||
#[arg(long, env = "FURUMI_OIDC_CLIENT_ID")]
|
||||
oidc_client_id: Option<String>,
|
||||
|
||||
/// OIDC Client Secret
|
||||
#[arg(long, env = "FURUMI_OIDC_CLIENT_SECRET")]
|
||||
oidc_client_secret: Option<String>,
|
||||
|
||||
/// OIDC Redirect URL (e.g. https://music.example.com/auth/callback)
|
||||
#[arg(long, env = "FURUMI_OIDC_REDIRECT_URL")]
|
||||
oidc_redirect_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn metrics_handler() -> String {
|
||||
@@ -115,7 +131,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
eprintln!("Error: Invalid web bind address '{}': {}", args.web_bind, e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
let web_app = web::build_router(root_path.clone(), args.token.clone());
|
||||
|
||||
// Initialize OIDC State if provided
|
||||
let oidc_state = if let (Some(issuer), Some(client_id), Some(secret), Some(redirect)) = (
|
||||
args.oidc_issuer_url,
|
||||
args.oidc_client_id,
|
||||
args.oidc_client_secret,
|
||||
args.oidc_redirect_url,
|
||||
) {
|
||||
println!("OIDC (SSO): enabled for web UI (issuer: {})", issuer);
|
||||
match web::auth::oidc_init(issuer, client_id, secret, redirect).await {
|
||||
Ok(state) => Some(Arc::new(state)),
|
||||
Err(e) => {
|
||||
eprintln!("Error initializing OIDC client: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let web_app = web::build_router(root_path.clone(), args.token.clone(), oidc_state);
|
||||
let web_listener = tokio::net::TcpListener::bind(web_addr).await?;
|
||||
println!("Web player: http://{}", web_addr);
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -9,7 +9,17 @@ use axum::{
|
||||
use sha2::{Digest, Sha256};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::WebState;
|
||||
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";
|
||||
@@ -39,9 +49,21 @@ pub async fn require_auth(
|
||||
.unwrap_or("");
|
||||
|
||||
let expected = token_hash(&state.token);
|
||||
let authed = cookies
|
||||
.split(';')
|
||||
.any(|c| c.trim() == format!("{}={}", SESSION_COOKIE, expected));
|
||||
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
|
||||
@@ -55,12 +77,49 @@ pub async fn require_auth(
|
||||
}
|
||||
}
|
||||
|
||||
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||
|
||||
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<WebState>) -> impl IntoResponse {
|
||||
if state.token.is_empty() {
|
||||
let token_enabled = !state.token.is_empty();
|
||||
let oidc_enabled = state.oidc.is_some();
|
||||
|
||||
if !token_enabled && !oidc_enabled {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
Html(LOGIN_HTML).into_response()
|
||||
|
||||
let html = LOGIN_HTML.replace(
|
||||
"<!-- OIDC_PLACEHOLDER -->",
|
||||
if oidc_enabled {
|
||||
r#"<div class="divider"><span>OR</span></div>
|
||||
<a href="/auth/login" class="btn-oidc">Log in with Authentik (SSO)</a>"#
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
Html(html).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -104,6 +163,176 @@ pub async fn logout() -> impl IntoResponse {
|
||||
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
||||
}
|
||||
|
||||
pub async fn oidc_init(
|
||||
issuer: String,
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
redirect: String,
|
||||
) -> anyhow::Result<OidcState> {
|
||||
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<WebState>,
|
||||
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::<CoreResponseType>::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<WebState>,
|
||||
axum::extract::Query(query): axum::extract::Query<AuthCallbackQuery>,
|
||||
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) => 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#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -144,6 +373,21 @@ const LOGIN_HTML: &str = r#"<!DOCTYPE html>
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover { background: #6b58e8; }
|
||||
.btn-oidc {
|
||||
display: block; width: 100%; padding: 0.65rem; text-align: center;
|
||||
background: #2a3347; border: 1px solid #3d4a66; border-radius: 8px;
|
||||
color: #e2e8f0; font-size: 0.95rem; font-weight: 600; text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-oidc:hover { background: #3d4a66; }
|
||||
.divider {
|
||||
display: flex; align-items: center; text-align: center; margin: 1.5rem 0;
|
||||
color: #64748b; font-size: 0.75rem;
|
||||
}
|
||||
.divider::before, .divider::after {
|
||||
content: ''; flex: 1; border-bottom: 1px solid #2a3347;
|
||||
}
|
||||
.divider span { padding: 0 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -155,6 +399,7 @@ const LOGIN_HTML: &str = r#"<!DOCTYPE html>
|
||||
<input type="password" id="password" name="password" autofocus autocomplete="current-password">
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
<!-- OIDC_PLACEHOLDER -->
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
@@ -18,13 +18,20 @@ use axum::{
|
||||
pub struct WebState {
|
||||
pub root: Arc<PathBuf>,
|
||||
pub token: Arc<String>,
|
||||
pub oidc: Option<Arc<OidcState>>,
|
||||
}
|
||||
|
||||
pub struct OidcState {
|
||||
pub client: openidconnect::core::CoreClient,
|
||||
pub session_secret: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Build the axum Router for the web player.
|
||||
pub fn build_router(root: PathBuf, token: String) -> Router {
|
||||
pub fn build_router(root: PathBuf, token: String, oidc: Option<Arc<OidcState>>) -> Router {
|
||||
let state = WebState {
|
||||
root: Arc::new(root),
|
||||
token: Arc::new(token),
|
||||
oidc,
|
||||
};
|
||||
|
||||
let api = Router::new()
|
||||
@@ -40,6 +47,8 @@ pub fn build_router(root: PathBuf, token: String) -> Router {
|
||||
Router::new()
|
||||
.route("/login", get(auth::login_page).post(auth::login_submit))
|
||||
.route("/logout", get(auth::logout))
|
||||
.route("/auth/login", get(auth::oidc_login))
|
||||
.route("/auth/callback", get(auth::oidc_callback))
|
||||
.merge(authed_routes)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user