add proper auth

This commit is contained in:
Pierre
2026-04-26 15:26:46 +02:00
parent f4ad0383cd
commit 9e6c3859c0
9 changed files with 454 additions and 16 deletions
@@ -13,6 +13,7 @@ import type { RenamingConfig, RenamingRule, PreviewResponse } from '../vfs/types
import { TEMPLATE_VARIABLES } from '../vfs/types/RenamingRuleTypes.js';
import { TemplateEngine } from '../vfs/template/TemplateEngine.js';
import { ConditionEvaluator } from '../vfs/template/ConditionEvaluator.js';
import { TokenStore } from '../webdav/TokenStore.js';
const logger = new Logger({ name: 'APIServer' });
@@ -30,6 +31,7 @@ export class APIServer {
private vfs: VirtualFileSystem;
private kvManager: KVManager | null = null;
private config: Required<APIServerConfig>;
private tokenStore: TokenStore;
constructor(vfs: VirtualFileSystem, config: APIServerConfig = {}, kvManager?: KVManager) {
this.vfs = vfs;
@@ -38,6 +40,10 @@ export class APIServer {
port: config.port ?? parseInt(process.env.API_PORT ?? '3000', 10),
host: config.host ?? process.env.API_HOST ?? '0.0.0.0',
};
// CONFIG_DIR is the same dir ConfigStorage uses for renaming rules.
this.tokenStore = new TokenStore({
configDir: process.env.CONFIG_DIR ?? '/meta-fuse/config',
});
this.app = Fastify({
logger: false,
@@ -88,6 +94,43 @@ export class APIServer {
// Service discovery — proxies to meta-core's /api/services, resolving the
// current leader's URL via LeaderClient (no hardcoded hostname).
this.app.get('/api/services', this.handleServices.bind(this));
// WebDAV access tokens (per-device basic-auth passwords for /webdav).
this.app.get('/api/webdav-tokens', this.handleListTokens.bind(this));
this.app.post('/api/webdav-tokens', this.handleCreateToken.bind(this));
this.app.delete('/api/webdav-tokens/:id', this.handleRevokeToken.bind(this));
}
private async handleListTokens(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
reply.send({ tokens: this.tokenStore.list() });
}
private async handleCreateToken(
req: FastifyRequest<{ Body: { label?: string } }>,
reply: FastifyReply
): Promise<void> {
const label = (req.body?.label ?? '').toString();
if (label.length > 200) {
reply.status(400).send({ error: 'label too long (max 200 chars)' });
return;
}
const { plaintext, view } = this.tokenStore.create(label);
// The plaintext is included exactly once in this response — the dashboard
// surfaces it to the user and then discards it. Subsequent GETs do not
// include it.
reply.status(201).send({ ...view, token: plaintext });
}
private async handleRevokeToken(
req: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
): Promise<void> {
const ok = this.tokenStore.revoke(req.params.id);
if (!ok) {
reply.status(404).send({ error: 'token not found' });
return;
}
reply.status(204).send();
}
/**
@@ -0,0 +1,138 @@
/**
* TokenStore — Per-device WebDAV access tokens.
*
* Tokens are minted from the OIDC-protected dashboard and used as the
* basic-auth password by WebDAV clients (Plex, Finder, etc.). The plaintext
* is shown to the user once at creation time; only sha256 hashes are
* persisted. The matching WsgiDAV DomainController
* (`docker/webdav_token_controller.py`) reads the same JSON file fresh on
* every auth request, so revocation is immediate.
*
* Tokens identify a *device*, not an OIDC user — multi-user support requires
* hash-lock to forward an X-Forwarded-User header (not currently the case),
* at which point a `userId` field can be added to the record.
*/
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { Logger } from 'tslog';
const logger = new Logger({ name: 'TokenStore' });
/** Length of the random portion (in hex chars) of a generated token. */
const TOKEN_RANDOM_HEX_LEN = 32; // 16 bytes => 128 bits of entropy
/** Public prefix marker so support can grep logs for stray tokens. */
const TOKEN_PREFIX = 'mfwd_';
/** Number of hex chars (after the prefix) we surface in the dashboard list. */
const VISIBLE_PREFIX_LEN = 4;
export interface WebdavTokenRecord {
id: string;
label: string;
hash: string; // sha256(plaintext)
prefix: string; // first VISIBLE_PREFIX_LEN chars of the random portion
createdAt: string; // ISO 8601
}
/** Public view shown in the dashboard list — no plaintext, no hash. */
export interface WebdavTokenView {
id: string;
label: string;
prefix: string;
createdAt: string;
}
interface TokenFile {
tokens: WebdavTokenRecord[];
}
export interface TokenStoreOptions {
configDir: string;
}
export class TokenStore {
private readonly file: string;
private readonly configDir: string;
constructor(opts: TokenStoreOptions) {
this.configDir = opts.configDir;
this.file = path.join(this.configDir, 'webdav-tokens.json');
this.ensureConfigDir();
}
private ensureConfigDir(): void {
if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true });
}
}
private read(): TokenFile {
if (!fs.existsSync(this.file)) return { tokens: [] };
try {
const raw = fs.readFileSync(this.file, 'utf-8');
const parsed = JSON.parse(raw) as TokenFile;
if (!Array.isArray(parsed.tokens)) return { tokens: [] };
return parsed;
} catch (err) {
logger.warn(`Token store ${this.file} unreadable, treating as empty:`, err);
return { tokens: [] };
}
}
/**
* Atomic write — write to a sibling tmp file and rename. WsgiDAV's
* controller reads this file on every auth request, so a partial write
* would briefly fail authentication.
*/
private write(file: TokenFile): void {
const tmp = `${this.file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(file, null, 2), 'utf-8');
fs.renameSync(tmp, this.file);
}
/** Public list — no plaintext, no hash. */
list(): WebdavTokenView[] {
return this.read().tokens.map(({ id, label, prefix, createdAt }) => ({
id, label, prefix, createdAt,
}));
}
/**
* Mint a new token. Returns the plaintext exactly once — the caller is
* responsible for surfacing it to the user and forgetting it.
*/
create(label: string): { plaintext: string; view: WebdavTokenView } {
const random = crypto.randomBytes(TOKEN_RANDOM_HEX_LEN / 2).toString('hex');
const plaintext = `${TOKEN_PREFIX}${random}`;
const id = crypto.randomBytes(8).toString('hex');
const record: WebdavTokenRecord = {
id,
label: label.trim() || 'Unnamed token',
hash: crypto.createHash('sha256').update(plaintext).digest('hex'),
prefix: random.slice(0, VISIBLE_PREFIX_LEN),
createdAt: new Date().toISOString(),
};
const file = this.read();
file.tokens.push(record);
this.write(file);
logger.info(`Minted WebDAV token id=${id} label=${JSON.stringify(record.label)}`);
return {
plaintext,
view: { id, label: record.label, prefix: record.prefix, createdAt: record.createdAt },
};
}
/** Delete by id. Returns true if a record was removed. */
revoke(id: string): boolean {
const file = this.read();
const idx = file.tokens.findIndex(t => t.id === id);
if (idx < 0) return false;
file.tokens.splice(idx, 1);
this.write(file);
logger.info(`Revoked WebDAV token id=${id}`);
return true;
}
}
+13
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { RulesEditor } from './components/RulesEditor';
import ServiceNav from './components/ServiceNav';
import { WebdavTokens } from './components/WebdavTokens';
interface Stats {
fileCount: number;
@@ -39,6 +40,7 @@ function App() {
const [refreshing, setRefreshing] = useState(false);
const [showRules, setShowRules] = useState(false);
const [showSetup, setShowSetup] = useState(false);
const [showTokens, setShowTokens] = useState(false);
const fetchData = useCallback(async () => {
try {
@@ -223,6 +225,17 @@ function App() {
<RulesEditor onSave={fetchData} />
)}
</div>
<div className="subsection">
<div
className="section-header-collapsible"
onClick={() => setShowTokens(!showTokens)}
>
<h3 className="subsection-title">WebDAV Access Tokens</h3>
<span className="collapse-icon">{showTokens ? '-' : '+'}</span>
</div>
{showTokens && <WebdavTokens />}
</div>
</div>
<div className="section">
@@ -0,0 +1,177 @@
import { useCallback, useEffect, useState } from 'react';
interface TokenView {
id: string;
label: string;
prefix: string;
createdAt: string;
}
interface CreatedToken extends TokenView {
token: string; // plaintext, surfaced only once
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
export function WebdavTokens() {
const [tokens, setTokens] = useState<TokenView[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newLabel, setNewLabel] = useState('');
const [creating, setCreating] = useState(false);
const [justCreated, setJustCreated] = useState<CreatedToken | null>(null);
const [copied, setCopied] = useState(false);
const refresh = useCallback(async () => {
try {
const res = await fetch('/api/webdav-tokens');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setTokens(data.tokens ?? []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const create = async () => {
setCreating(true);
try {
const res = await fetch('/api/webdav-tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: newLabel }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const created: CreatedToken = await res.json();
setJustCreated(created);
setNewLabel('');
setCopied(false);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCreating(false);
}
};
const revoke = async (id: string, label: string) => {
if (!confirm(`Revoke token "${label}"? Devices using it will lose WebDAV access immediately.`)) return;
try {
const res = await fetch(`/api/webdav-tokens/${id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const copyToken = async () => {
if (!justCreated) return;
try {
await navigator.clipboard.writeText(justCreated.token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API may be unavailable on http; fall back silently.
}
};
if (loading) return <div className="loading">Loading tokens…</div>;
return (
<div className="webdav-tokens">
<p className="info-note">
Generate one token per device and paste it as the basic-auth password
when you add the WebDAV mount in Plex / Finder / Windows Explorer / etc.
Any username works — the password is what authenticates. Revoke a
token to cut off that single device without affecting the others.
</p>
{error && <div className="error">{error}</div>}
{justCreated && (
<div className="info-box" style={{ borderColor: '#10b981' }}>
<h4>New token — copy it now</h4>
<p className="info-note">
This is the only time the full token will be shown. After you close
this banner only the prefix will appear in the list.
</p>
<div className="command-box" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<code style={{ flex: 1, wordBreak: 'break-all' }}>{justCreated.token}</code>
<button onClick={copyToken} className="refresh-btn">
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<button
onClick={() => setJustCreated(null)}
className="refresh-btn"
style={{ marginTop: '0.5rem' }}
>
Dismiss
</button>
</div>
)}
<div className="card" style={{ marginTop: '1rem' }}>
<div className="card-header">
<span className="card-title">Generate token</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<input
type="text"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="Label (e.g. 'Plex on living-room TV')"
maxLength={200}
style={{ flex: 1, padding: '0.5rem' }}
onKeyDown={(e) => { if (e.key === 'Enter' && !creating) create(); }}
/>
<button onClick={create} disabled={creating} className="refresh-btn">
{creating ? 'Generating…' : 'Generate'}
</button>
</div>
</div>
<div className="file-list" style={{ marginTop: '1rem' }}>
{tokens.length === 0 ? (
<div className="file-item">
<span className="file-name" style={{ color: '#64748b' }}>
No tokens yet — generate one to enable WebDAV access.
</span>
</div>
) : (
tokens.map((t) => (
<div key={t.id} className="file-item" style={{ justifyContent: 'space-between' }}>
<div>
<span className="file-name"><strong>{t.label}</strong></span>
<div className="info-note">
<code>mfwd_{t.prefix}…</code> · created {formatDate(t.createdAt)}
</div>
</div>
<button
onClick={() => revoke(t.id, t.label)}
className="refresh-btn"
style={{ background: '#ef4444', color: 'white' }}
>
Revoke
</button>
</div>
))
)}
</div>
</div>
);
}