diff --git a/.github/workflows/docker-publish-node-player-dev.yml b/.github/workflows/docker-publish-node-player-dev.yml new file mode 100644 index 0000000..d0ec036 --- /dev/null +++ b/.github/workflows/docker-publish-node-player-dev.yml @@ -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 diff --git a/.github/workflows/docker-publish-node-player.yml b/.github/workflows/docker-publish-node-player.yml new file mode 100644 index 0000000..41d2693 --- /dev/null +++ b/.github/workflows/docker-publish-node-player.yml @@ -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 diff --git a/.gitignore b/.gitignore index eeb9a57..2f3c66b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /target -/inbox -/storage +/docker/inbox +/docker/storage .env diff --git a/Cargo.lock b/Cargo.lock index 9cb8bb9..87c6251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/docker/Dockerfile.agent b/docker/Dockerfile.agent index d0c2da4..ed676b9 100644 --- a/docker/Dockerfile.agent +++ b/docker/Dockerfile.agent @@ -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 diff --git a/docker/Dockerfile.node-player b/docker/Dockerfile.node-player new file mode 100644 index 0000000..0ad2d3d --- /dev/null +++ b/docker/Dockerfile.node-player @@ -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"] diff --git a/docker/Dockerfile.web-player b/docker/Dockerfile.web-player index ee6902a..1ae113c 100644 --- a/docker/Dockerfile.web-player +++ b/docker/Dockerfile.web-player @@ -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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 100a9a8..438546d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 diff --git a/furumi-agent/prompts/normalize.txt b/furumi-agent/prompts/normalize.txt index fe8577d..d049bc3 100644 --- a/furumi-agent/prompts/normalize.txt +++ b/furumi-agent/prompts/normalize.txt @@ -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. diff --git a/furumi-agent/src/ingest/normalize.rs b/furumi-agent/src/ingest/normalize.rs index bd8020c..cc19bce 100644 --- a/furumi-agent/src/ingest/normalize.rs +++ b/furumi-agent/src/ingest/normalize.rs @@ -25,16 +25,37 @@ pub async fn normalize( ) -> anyhow::Result { 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, - format: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + response_format: Option, 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, } #[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 { 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 { genre: Option, #[serde(default)] featured_artists: Vec, + #[serde(rename = "release_kind")] release_type: Option, confidence: Option, notes: Option, diff --git a/furumi-agent/src/merge.rs b/furumi-agent/src/merge.rs index 25f6e5b..8c7f83c 100644 --- a/furumi-agent/src/merge.rs +++ b/furumi-agent/src/merge.rs @@ -35,12 +35,39 @@ pub async fn propose_merge(state: &Arc, 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)?; diff --git a/furumi-node-player/client/.env.example b/furumi-node-player/client/.env.example index 2312cf7..eeec224 100644 --- a/furumi-node-player/client/.env.example +++ b/furumi-node-player/client/.env.example @@ -1,2 +1 @@ -VITE_API_BASE_URL=http://localhost:8085 -VITE_API_KEY= \ No newline at end of file +VITE_FURUMI_API_URL=http://localhost:8085 diff --git a/furumi-node-player/client/src/App.tsx b/furumi-node-player/client/src/App.tsx index 51a08a4..fed1fdb 100644 --- a/furumi-node-player/client/src/App.tsx +++ b/furumi-node-player/client/src/App.tsx @@ -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 ( <> diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index bdf4114..176166a 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -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 { const res = await furumiApi.get('/artists').catch(() => null) return res?.data ?? null diff --git a/furumi-node-player/client/vite.config.ts b/furumi-node-player/client/vite.config.ts index ab8ee06..58fa2c9 100644 --- a/furumi-node-player/client/vite.config.ts +++ b/furumi-node-player/client/vite.config.ts @@ -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, }, diff --git a/furumi-node-player/server/src/index.ts b/furumi-node-player/server/src/index.ts index 59622ad..93f7f29 100644 --- a/furumi-node-player/server/src/index.ts +++ b/furumi-node-player/server/src/index.ts @@ -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}`, diff --git a/furumi-web-player/Cargo.toml b/furumi-web-player/Cargo.toml index 822300c..6638f38 100644 --- a/furumi-web-player/Cargo.toml +++ b/furumi-web-player/Cargo.toml @@ -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" diff --git a/furumi-web-player/src/main.rs b/furumi-web-player/src/main.rs index b8a8592..f95c39b 100644 --- a/furumi-web-player/src/main.rs +++ b/furumi-web-player/src/main.rs @@ -40,9 +40,6 @@ struct Args { #[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")] oidc_session_secret: Option, - /// API key for x-api-key header auth (alternative to OIDC session) - #[arg(long, env = "FURUMI_PLAYER_API_KEY")] - api_key: Option, } #[tokio::main] @@ -94,15 +91,10 @@ async fn main() -> Result<(), Box> { 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); diff --git a/furumi-web-player/src/web/auth.rs b/furumi-web-player/src/web/auth.rs index 33f8184..98574f7 100644 --- a/furumi-web-player/src/web/auth.rs +++ b/furumi-web-player/src/web/auth.rs @@ -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; pub struct OidcState { pub client: CoreClient, pub session_secret: Vec, + jwks_uri: String, + issuer_url: String, + jwks_cache: RwLock>, + 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 { + { + 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 { + 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 { + 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::(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 { } } -/// 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>, 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>) -> 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#" - - - - -Furumi Player — Login - - - -
- -
Sign in to continue
- SSO Login -
- -"#; diff --git a/furumi-web-player/src/web/mod.rs b/furumi-web-player/src/web/mod.rs index 355b4bb..c8d95b9 100644 --- a/furumi-web-player/src/web/mod.rs +++ b/furumi-web-player/src/web/mod.rs @@ -16,7 +16,6 @@ pub struct AppState { #[allow(dead_code)] pub storage_dir: Arc, pub oidc: Option>, - pub api_key: Option, } pub fn build_router(state: Arc) -> Router { @@ -32,37 +31,27 @@ pub fn build_router(state: Arc) -> 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 { - let html = include_str!("player.html") - .replace("", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))); - axum::response::Html(html) -}