feat(auth): replace cookie/api-key auth with JWT Bearer tokens, separate UI from API
Publish Metadata Agent Image / build-and-push-image (push) Successful in 6m3s
Publish Node Player Image / build-and-push-image (push) Failing after 58s
Publish Web Player Image / build-and-push-image (push) Has been cancelled

- Add JWT Bearer token validation to Rust API via OIDC provider JWKS
  with automatic key rotation and 1-hour cache
- Remove x-api-key auth support and built-in web UI from furumi-web-player,
  leaving it as a pure API server
- Add /auth/token endpoint to Node player server to expose OIDC access
  tokens to the frontend
- Move Node player auth endpoints from /api/* to /auth/* to avoid
  path conflicts with Rust API
- Add static file serving to Node Express server for production
  single-container deployment
- Fix SameSite=Strict cookie issue breaking OIDC redirect flow (use Lax)
- Add Dockerfile.node-player with multi-stage Node.js build
- Add CI workflows for node-player Docker image (dev + release)
- Optimize Rust Dockerfiles with dependency caching layer
- Update docker-compose with OIDC env vars and OLLAMA_MODEL support
- Cherry-pick agent LLM client fixes from DEV branch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ultradesu
2026-04-08 14:51:52 +01:00
parent 94d14e8fc8
commit e99cacae8b
20 changed files with 515 additions and 161 deletions
+82 -84
View File
@@ -3,10 +3,9 @@ use axum::{
extract::{Request, State},
http::{header, HeaderMap, StatusCode},
middleware::Next,
response::{Html, IntoResponse, Redirect, Response},
response::{IntoResponse, Redirect, Response},
};
const X_API_KEY: &str = "x-api-key";
use openidconnect::{
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
reqwest::async_http_client,
@@ -18,17 +17,26 @@ use serde::Deserialize;
use base64::Engine;
use hmac::{Hmac, Mac};
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation as JwtValidation};
use jsonwebtoken::jwk::JwkSet;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use super::AppState;
use std::sync::Arc;
const SESSION_COOKIE: &str = "furumi_session";
const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600);
type HmacSha256 = Hmac<sha2::Sha256>;
pub struct OidcState {
pub client: CoreClient,
pub session_secret: Vec<u8>,
jwks_uri: String,
issuer_url: String,
jwks_cache: RwLock<Option<(JwkSet, Instant)>>,
http_client: reqwest::Client,
}
pub async fn oidc_init(
@@ -44,6 +52,9 @@ pub async fn oidc_init(
)
.await?;
let jwks_uri = provider_metadata.jwks_uri().to_string();
let issuer_url = provider_metadata.issuer().to_string();
let client = CoreClient::from_provider_metadata(
provider_metadata,
ClientId::new(client_id),
@@ -62,12 +73,70 @@ pub async fn oidc_init(
b
};
let http_client = reqwest::Client::new();
tracing::info!("JWKS URI: {}", jwks_uri);
Ok(OidcState {
client,
session_secret,
jwks_uri,
issuer_url,
jwks_cache: RwLock::new(None),
http_client,
})
}
impl OidcState {
async fn get_jwks(&self) -> anyhow::Result<JwkSet> {
{
let cache = self.jwks_cache.read().await;
if let Some((ref jwks, fetched_at)) = *cache {
if fetched_at.elapsed() < JWKS_CACHE_TTL {
return Ok(jwks.clone());
}
}
}
self.refresh_jwks().await
}
async fn refresh_jwks(&self) -> anyhow::Result<JwkSet> {
tracing::debug!("Fetching JWKS from {}", self.jwks_uri);
let jwks: JwkSet = self.http_client.get(&self.jwks_uri).send().await?.json().await?;
let mut cache = self.jwks_cache.write().await;
*cache = Some((jwks.clone(), Instant::now()));
Ok(jwks)
}
}
#[derive(Debug, serde::Deserialize)]
struct BearerClaims {
sub: String,
}
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<String> {
let header = decode_header(token).ok()?;
let kid = header.kid.as_ref()?;
let mut jwks = oidc.get_jwks().await.ok()?;
let mut jwk = jwks.find(kid);
// Handle key rotation: refresh JWKS if kid not found
if jwk.is_none() {
jwks = oidc.refresh_jwks().await.ok()?;
jwk = jwks.find(kid);
}
let key = DecodingKey::from_jwk(jwk?).ok()?;
let mut validation = JwtValidation::new(header.alg);
validation.set_issuer(&[&oidc.issuer_url]);
validation.validate_aud = false;
let data = decode::<BearerClaims>(token, &key, &validation).ok()?;
Some(data.claims.sub)
}
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());
@@ -94,20 +163,22 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
}
}
/// Auth middleware: requires valid SSO session cookie or x-api-key header.
/// Auth middleware: requires valid Bearer JWT or SSO session cookie.
pub async fn require_auth(
State(state): State<Arc<AppState>>,
req: Request,
next: Next,
) -> Response {
// 1. Check x-api-key header (if configured)
if let Some(ref expected) = state.api_key {
if let Some(val) = req
// 1. Check Bearer token — JWT from OIDC provider
if let Some(ref oidc) = state.oidc {
if let Some(token) = req
.headers()
.get(X_API_KEY)
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
{
if val == expected {
if let Some(user_id) = validate_bearer_token(oidc, token).await {
tracing::debug!("Bearer auth OK for user: {}", user_id);
return next.run(req).await;
}
}
@@ -131,36 +202,7 @@ pub async fn require_auth(
}
}
let uri = req.uri().to_string();
if uri.starts_with("/api/") {
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
} else if state.oidc.is_some() {
Redirect::to("/login").into_response()
} else {
// Only API key configured — no web login available
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
}
}
/// GET /login — show SSO login page.
pub async fn login_page(State(state): State<Arc<AppState>>) -> impl IntoResponse {
if state.oidc.is_none() {
return Redirect::to("/").into_response();
}
Html(LOGIN_HTML).into_response()
}
/// GET /logout — clear session cookie.
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()
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
}
#[derive(Deserialize)]
@@ -335,9 +377,9 @@ pub async fn oidc_callback(
.unwrap_or(false);
let session_attrs = if is_https {
"SameSite=Strict; Secure"
"SameSite=Lax; Secure"
} else {
"SameSite=Strict"
"SameSite=Lax"
};
let session_cookie = format!(
@@ -354,47 +396,3 @@ pub async fn oidc_callback(
(StatusCode::FOUND, headers, Body::empty()).into_response()
}
const LOGIN_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Furumi Player — Login</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex; align-items: center; justify-content: center;
background: #0d0f14;
font-family: 'Inter', system-ui, sans-serif;
color: #e2e8f0;
}
.card {
background: #161b27;
border: 1px solid #2a3347;
border-radius: 16px;
padding: 2.5rem 3rem;
width: 360px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
text-align: center;
}
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
.btn-sso {
display: block; width: 100%; padding: 0.75rem; text-align: center;
background: #7c6af7; border: none; border-radius: 8px;
color: #fff; font-size: 0.95rem; font-weight: 600; text-decoration: none;
cursor: pointer; transition: background 0.2s;
}
.btn-sso:hover { background: #6b58e8; }
</style>
</head>
<body>
<div class="card">
<div class="logo">Furumi</div>
<div class="subtitle">Sign in to continue</div>
<a href="/auth/login" class="btn-sso">SSO Login</a>
</div>
</body>
</html>"#;