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
@@ -0,0 +1,40 @@
name: Publish Node Player Image (dev)
on:
push:
branches:
- DEV
env:
REGISTRY: docker.io
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.node-player
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -0,0 +1,57 @@
name: Publish Node Player Image
on:
push:
branches:
- '**'
- '!DEV'
tags:
- 'v*.*.*'
env:
REGISTRY: docker.io
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine version and tags
id: info
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-7)"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
echo "tags=${IMAGE}:${VERSION},${IMAGE}:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=${IMAGE}:trunk,${IMAGE}:${SHORT_SHA}" >> "$GITHUB_OUTPUT"
fi
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.node-player
push: true
tags: ${{ steps.info.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -2
View File
@@ -1,4 +1,4 @@
/target
/inbox
/storage
/docker/inbox
/docker/storage
.env
Generated
+18 -1
View File
@@ -1114,7 +1114,7 @@ dependencies = [
"futures-core",
"futures-util",
"hmac",
"jsonwebtoken",
"jsonwebtoken 10.3.0",
"libc",
"mime_guess",
"ogg",
@@ -1152,6 +1152,7 @@ dependencies = [
"base64 0.22.1",
"clap",
"hmac",
"jsonwebtoken 9.3.1",
"mime_guess",
"openidconnect",
"rand 0.8.5",
@@ -1165,6 +1166,7 @@ dependencies = [
"tokio",
"tokio-util",
"tower 0.4.13",
"tower-http",
"tracing",
"tracing-subscriber",
"urlencoding",
@@ -1864,6 +1866,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "jsonwebtoken"
version = "10.3.0"
+27
View File
@@ -8,8 +8,35 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
# 1. Copy workspace manifests and lock file (changes rarely → cached layer)
COPY Cargo.toml Cargo.lock ./
COPY furumi-common/Cargo.toml furumi-common/Cargo.toml
COPY furumi-server/Cargo.toml furumi-server/Cargo.toml
COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml
COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml
COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml
COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml
COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml
# 2. Create dummy sources so cargo can resolve and build dependencies
RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \
&& mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \
&& mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \
&& mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \
&& mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \
&& mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \
&& mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs
# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change)
RUN cargo build --release --bin furumi-agent 2>/dev/null || true
# 4. Copy real source code
COPY . .
# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps)
RUN touch furumi-common/src/lib.rs furumi-agent/src/main.rs
ARG FURUMI_VERSION=dev
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent
+38
View File
@@ -0,0 +1,38 @@
FROM node:22-alpine AS build
WORKDIR /app
# 1. Install server dependencies (cached layer)
COPY furumi-node-player/server/package.json furumi-node-player/server/package-lock.json ./server/
RUN cd server && npm ci
# 2. Install client dependencies (cached layer)
COPY furumi-node-player/client/package.json furumi-node-player/client/package-lock.json ./client/
RUN cd client && npm ci
# 3. Build server
COPY furumi-node-player/server/ ./server/
RUN cd server && npm run build
# 4. Build client (VITE_FURUMI_API_URL empty = relative /api on same origin)
COPY furumi-node-player/client/ ./client/
RUN cd client && npm run build
FROM node:22-alpine
WORKDIR /app
# Server runtime
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/server/node_modules ./server/node_modules
COPY --from=build /app/server/package.json ./server/
# Client static files
COPY --from=build /app/client/dist ./client/dist
ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001
CMD ["node", "server/dist/index.js"]
+27
View File
@@ -8,8 +8,35 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
# 1. Copy workspace manifests and lock file (changes rarely → cached layer)
COPY Cargo.toml Cargo.lock ./
COPY furumi-common/Cargo.toml furumi-common/Cargo.toml
COPY furumi-server/Cargo.toml furumi-server/Cargo.toml
COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml
COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml
COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml
COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml
COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml
# 2. Create dummy sources so cargo can resolve and build dependencies
RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \
&& mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \
&& mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \
&& mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \
&& mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \
&& mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \
&& mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs
# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change)
RUN cargo build --release --bin furumi-web-player 2>/dev/null || true
# 4. Copy real source code
COPY . .
# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps)
RUN touch furumi-common/src/lib.rs furumi-web-player/src/main.rs
ARG FURUMI_VERSION=dev
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player
+11 -5
View File
@@ -16,8 +16,8 @@ services:
agent:
build:
context: .
dockerfile: Dockerfile.agent
context: ..
dockerfile: docker/Dockerfile.agent
container_name: furumi-agent
depends_on:
db:
@@ -25,10 +25,12 @@ services:
ports:
- "8090:8090"
environment:
RUST_LOG: info
FURUMI_AGENT_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
FURUMI_AGENT_INBOX_DIR: "/inbox"
FURUMI_AGENT_STORAGE_DIR: "/storage"
FURUMI_AGENT_OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
FURUMI_AGENT_OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:14b}"
FURUMI_AGENT_OLLAMA_AUTH: "${OLLAMA_AUTH:-CHANGE-ME}"
FURUMI_PLAYER_BIND: "0.0.0.0:8090"
FURUMI_AGENT_POLL_INTERVAL_SECS: 5
@@ -41,8 +43,8 @@ services:
web-player:
build:
context: .
dockerfile: Dockerfile.web-player
context: ..
dockerfile: docker/Dockerfile.web-player
container_name: furumi-web-player
depends_on:
db:
@@ -53,7 +55,11 @@ services:
FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
FURUMI_PLAYER_STORAGE_DIR: "/storage"
FURUMI_PLAYER_BIND: "0.0.0.0:8085"
FURUMI_PLAYER_API_KEY: "node-player-api-key"
FURUMI_PLAYER_OIDC_ISSUER_URL: "${OIDC_ISSUER_URL}"
FURUMI_PLAYER_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
FURUMI_PLAYER_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}"
FURUMI_PLAYER_OIDC_REDIRECT_URL: "${OIDC_REDIRECT_URL}"
FURUMI_PLAYER_OIDC_SESSION_SECRET: "${OIDC_SESSION_SECRET}"
volumes:
- ./storage:/storage
restart: always
+1 -1
View File
@@ -64,7 +64,7 @@ You are a music metadata normalization assistant. Your job is to take raw metada
10. **Consistency**: When the database already contains entries for an artist or album, your output MUST match the existing canonical names. Do not introduce new variations.
11. **Confidence**: Rate your confidence from 0.0 to 1.0.
11. **Confidence**: MUST be a decimal number between 0.0 and 1.0 (e.g., 0.95, 0.7, 0.3). NEVER use words like "high", "medium", "low" — only a numeric float value.
- 1.0: All fields are clear and unambiguous.
- 0.8+: Minor inferences made (e.g., year from path), but high certainty.
- 0.5-0.8: Some guesswork involved, human review recommended.
+101 -25
View File
@@ -25,16 +25,37 @@ pub async fn normalize(
) -> anyhow::Result<NormalizedFields> {
let user_message = build_user_message(raw, hints, similar_artists, similar_albums, folder_ctx);
let schema = normalize_schema();
let response = call_ollama(
&state.config.ollama_url,
&state.config.ollama_model,
&state.system_prompt,
&user_message,
state.config.ollama_auth.as_deref(),
0.5,
512,
Some(("normalized_metadata", schema.clone())),
)
.await?;
parse_response(&response)
match parse_response(&response) {
Ok(fields) => Ok(fields),
Err(e) => {
tracing::warn!(error = %e, "LLM parse failed, retrying with higher frequency_penalty");
let response2 = call_ollama(
&state.config.ollama_url,
&state.config.ollama_model,
&state.system_prompt,
&user_message,
state.config.ollama_auth.as_deref(),
1.5,
512,
Some(("normalized_metadata", schema)),
)
.await?;
parse_response(&response2)
}
}
}
fn build_user_message(
@@ -113,32 +134,49 @@ fn build_user_message(
}
#[derive(Serialize)]
struct OllamaRequest {
struct ChatRequest {
model: String,
messages: Vec<OllamaMessage>,
format: String,
messages: Vec<ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
response_format: Option<ChatResponseFormat>,
stream: bool,
options: OllamaOptions,
temperature: f64,
max_tokens: u32,
frequency_penalty: f64,
}
#[derive(Serialize)]
struct OllamaMessage {
struct ChatMessage {
role: String,
content: String,
}
#[derive(Serialize)]
struct OllamaOptions {
temperature: f64,
struct ChatResponseFormat {
#[serde(rename = "type")]
kind: String,
json_schema: JsonSchemaWrapper,
}
#[derive(Serialize)]
struct JsonSchemaWrapper {
name: String,
strict: bool,
schema: serde_json::Value,
}
#[derive(Deserialize)]
struct OllamaResponse {
message: OllamaResponseMessage,
struct ChatResponse {
choices: Vec<ChatChoice>,
}
#[derive(Deserialize)]
struct OllamaResponseMessage {
struct ChatChoice {
message: ChatResponseMessage,
}
#[derive(Deserialize)]
struct ChatResponseMessage {
content: String,
}
@@ -148,30 +186,40 @@ pub async fn call_ollama(
system_prompt: &str,
user_message: &str,
auth: Option<&str>,
frequency_penalty: f64,
max_tokens: u32,
schema: Option<(&str, serde_json::Value)>,
) -> anyhow::Result<String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()?;
let request = OllamaRequest {
let response_format = schema.map(|(name, schema)| ChatResponseFormat {
kind: "json_schema".to_owned(),
json_schema: JsonSchemaWrapper { name: name.to_owned(), strict: true, schema },
});
let request = ChatRequest {
model: model.to_owned(),
messages: vec![
OllamaMessage {
ChatMessage {
role: "system".to_owned(),
content: system_prompt.to_owned(),
},
OllamaMessage {
ChatMessage {
role: "user".to_owned(),
content: user_message.to_owned(),
},
],
format: "json".to_owned(),
response_format,
stream: false,
options: OllamaOptions { temperature: 0.1 },
temperature: 0.1,
max_tokens,
frequency_penalty,
};
let url = format!("{}/api/chat", base_url.trim_end_matches('/'));
tracing::info!(%url, model, prompt_len = user_message.len(), "Calling Ollama API...");
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
tracing::info!(%url, model, prompt_len = user_message.len(), "Calling LLM API...");
let start = std::time::Instant::now();
let mut req = client.post(&url).json(&request);
@@ -184,18 +232,45 @@ pub async fn call_ollama(
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
tracing::error!(%status, body = &body[..body.len().min(500)], "Ollama API error");
anyhow::bail!("Ollama returned {}: {}", status, body);
tracing::error!(%status, body = &body[..body.len().min(500)], "LLM API error");
anyhow::bail!("LLM returned {}: {}", status, body);
}
let ollama_resp: OllamaResponse = resp.json().await?;
let chat_resp: ChatResponse = resp.json().await?;
let content = chat_resp
.choices
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("LLM returned empty choices"))?
.message
.content;
tracing::info!(
elapsed_ms = elapsed.as_millis() as u64,
response_len = ollama_resp.message.content.len(),
"Ollama response received"
response_len = content.len(),
"LLM response received"
);
tracing::debug!(raw_response = %ollama_resp.message.content, "LLM raw output");
Ok(ollama_resp.message.content)
tracing::debug!(raw_response = %content, "LLM raw output");
Ok(content)
}
fn normalize_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"artist": { "type": ["string", "null"] },
"album": { "type": ["string", "null"] },
"title": { "type": ["string", "null"] },
"year": { "type": ["integer", "null"] },
"track_number": { "type": ["integer", "null"] },
"genre": { "type": ["string", "null"] },
"featured_artists": { "type": "array", "items": { "type": "string" } },
"release_kind": { "type": ["string", "null"] },
"confidence": { "type": ["number", "null"] },
"notes": { "type": ["string", "null"] }
},
"required": ["artist", "album", "title", "year", "track_number", "genre", "featured_artists", "release_kind", "confidence", "notes"],
"additionalProperties": false
})
}
/// Parse the LLM JSON response into NormalizedFields.
@@ -222,6 +297,7 @@ fn parse_response(response: &str) -> anyhow::Result<NormalizedFields> {
genre: Option<String>,
#[serde(default)]
featured_artists: Vec<String>,
#[serde(rename = "release_kind")]
release_type: Option<String>,
confidence: Option<f64>,
notes: Option<String>,
+27
View File
@@ -35,12 +35,39 @@ pub async fn propose_merge(state: &Arc<AppState>, merge_id: Uuid) -> anyhow::Res
let user_message = build_merge_message(&artists_data);
let schema = serde_json::json!({
"type": "object",
"properties": {
"canonical_artist_name": { "type": "string" },
"winner_artist_id": { "type": "integer" },
"album_mappings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source_album_id": { "type": "integer" },
"canonical_name": { "type": "string" },
"merge_into_album_id": { "type": ["integer", "null"] }
},
"required": ["source_album_id", "canonical_name", "merge_into_album_id"],
"additionalProperties": false
}
},
"notes": { "type": "string" }
},
"required": ["canonical_artist_name", "winner_artist_id", "album_mappings", "notes"],
"additionalProperties": false
});
let response = call_ollama(
&state.config.ollama_url,
&state.config.ollama_model,
&state.merge_prompt,
&user_message,
state.config.ollama_auth.as_deref(),
0.5,
4096,
Some(("artist_merge", schema)),
).await?;
let proposal = parse_merge_response(&response)?;
+1 -2
View File
@@ -1,2 +1 @@
VITE_API_BASE_URL=http://localhost:8085
VITE_API_KEY=
VITE_FURUMI_API_URL=http://localhost:8085
+25 -6
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { FurumiPlayer } from './FurumiPlayer'
import { setAuthToken, clearAuthToken } from './furumiApi'
import './App.css'
type UserProfile = {
@@ -22,7 +23,7 @@ function App() {
}
})
const apiBase = useMemo(() => import.meta.env.VITE_API_BASE_URL ?? '', [])
const apiBase = ''
useEffect(() => {
if (runWithoutAuth) {
@@ -34,12 +35,13 @@ function App() {
const loadMe = async () => {
try {
const response = await fetch(`${apiBase}/api/me`, {
const response = await fetch(`${apiBase}/auth/me`, {
credentials: 'include',
})
if (response.status === 401) {
setUser(null)
clearAuthToken()
return
}
@@ -49,6 +51,23 @@ function App() {
const data = await response.json()
setUser(data.user ?? null)
// Fetch OIDC access token for Rust API Bearer auth
if (data.user) {
try {
const tokenRes = await fetch(`${apiBase}/auth/token`, {
credentials: 'include',
})
if (tokenRes.ok) {
const tokenData = await tokenRes.json()
if (tokenData.access_token) {
setAuthToken(tokenData.access_token)
}
}
} catch {
// Token fetch failed — API calls will fall back to other auth methods
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load session')
} finally {
@@ -57,10 +76,10 @@ function App() {
}
void loadMe()
}, [apiBase, runWithoutAuth])
}, [runWithoutAuth])
const loginUrl = `${apiBase}/api/login`
const logoutUrl = `${apiBase}/api/logout`
const loginUrl = `${apiBase}/auth/login`
const logoutUrl = `${apiBase}/auth/logout`
return (
<>
+10 -5
View File
@@ -1,16 +1,21 @@
import axios from 'axios'
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''
export const API_ROOT = `${API_BASE}/api`
const API_KEY = import.meta.env.VITE_API_KEY
const FURUMI_API_BASE = import.meta.env.VITE_FURUMI_API_URL ?? ''
export const API_ROOT = `${FURUMI_API_BASE}/api`
export const furumiApi = axios.create({
baseURL: API_ROOT,
headers: API_KEY ? { 'x-api-key': API_KEY } : {},
})
export function setAuthToken(token: string) {
furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}`
}
export function clearAuthToken() {
delete furumiApi.defaults.headers.common['Authorization']
}
export async function getArtists(): Promise<Artist[] | null> {
const res = await furumiApi.get<Artist[]>('/artists').catch(() => null)
return res?.data ?? null
+5 -1
View File
@@ -6,7 +6,11 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
'/auth': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/callback': {
target: 'http://localhost:3001',
changeOrigin: true,
},
+37 -5
View File
@@ -1,5 +1,6 @@
import 'dotenv/config';
import path from 'path';
import cors from 'cors';
import express from 'express';
import { auth } from 'express-openid-connect';
@@ -28,7 +29,6 @@ const oidcConfig = {
};
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
// Keep a clear startup failure if OIDC is not configured.
throw new Error(
'OIDC config is missing. Set OIDC_ISSUER_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET in server/.env (or set DISABLE_AUTH=true)',
);
@@ -46,11 +46,11 @@ if (!disableAuth) {
app.use(auth(oidcConfig));
}
app.get('/api/health', (_req, res) => {
app.get('/auth/health', (_req, res) => {
res.json({ ok: true });
});
app.get('/api/me', (req, res) => {
app.get('/auth/me', (req, res) => {
if (disableAuth) {
res.json({
authenticated: false,
@@ -74,7 +74,32 @@ app.get('/api/me', (req, res) => {
});
});
app.get('/api/login', (req, res) => {
app.get('/auth/token', (req, res) => {
if (disableAuth) {
res.status(204).end();
return;
}
if (!req.oidc.isAuthenticated()) {
res.status(401).json({ authenticated: false });
return;
}
const accessToken = req.oidc.accessToken?.access_token;
const expiresAt = req.oidc.accessToken?.expires_at;
if (!accessToken) {
res.status(500).json({ error: 'no access token in session' });
return;
}
res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_at: expiresAt,
});
});
app.get('/auth/login', (req, res) => {
if (disableAuth) {
res.status(204).end();
return;
@@ -85,7 +110,7 @@ app.get('/api/login', (req, res) => {
});
});
app.get('/api/logout', (req, res) => {
app.get('/auth/logout', (req, res) => {
if (disableAuth) {
res.status(204).end();
return;
@@ -96,6 +121,13 @@ app.get('/api/logout', (req, res) => {
});
});
// Production: serve Vite-built client as static files
const clientDist = path.resolve(import.meta.dirname, '../../client/dist');
app.use(express.static(clientDist));
app.get('*', (_req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
app.listen(port, () => {
console.log(
`${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`,
+2 -1
View File
@@ -18,7 +18,8 @@ mime_guess = "2.0"
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
tokio-util = { version = "0.7", features = ["io"] }
openidconnect = "3.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
jsonwebtoken = "9"
sha2 = "0.10"
hmac = "0.12"
base64 = "0.22"
-8
View File
@@ -40,9 +40,6 @@ struct Args {
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
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]
@@ -94,15 +91,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1);
});
if args.api_key.is_some() {
tracing::info!("x-api-key auth: enabled");
}
let state = Arc::new(web::AppState {
pool,
storage_dir: Arc::new(args.storage_dir),
oidc: oidc_state,
api_key: args.api_key,
});
tracing::info!("Web player: http://{}", bind_addr);
+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>"#;
+4 -15
View File
@@ -16,7 +16,6 @@ pub struct AppState {
#[allow(dead_code)]
pub storage_dir: Arc<PathBuf>,
pub oidc: Option<Arc<auth::OidcState>>,
pub api_key: Option<String>,
}
pub fn build_router(state: Arc<AppState>) -> Router {
@@ -32,37 +31,27 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/stream/:slug", get(api::stream_track))
.route("/search", get(api::search));
let authed = Router::new()
.route("/", get(player_html))
let api = Router::new()
.nest("/api", library);
let requires_auth = state.oidc.is_some();
let app = if requires_auth {
authed
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
api.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
} else {
authed
api
};
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::OPTIONS, Method::HEAD])
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::HeaderName::from_static("x-api-key")])
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION])
.max_age(Duration::from_secs(600));
Router::new()
.route("/login", get(auth::login_page))
.route("/logout", get(auth::logout))
.route("/auth/login", get(auth::oidc_login))
.route("/auth/callback", get(auth::oidc_callback))
.merge(app)
.layer(cors)
.with_state(state)
}
async fn player_html() -> axum::response::Html<String> {
let html = include_str!("player.html")
.replace("<!-- VERSION_PLACEHOLDER -->", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")));
axum::response::Html(html)
}