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
+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}`,