@@ -424,17 +456,18 @@ export async function render(container, { user }) {
});
}
- bindEvents(container, user, categories, icsSubscriptions);
+ bindEvents(container, user, categories, icsSubscriptions, apiTokens);
}
// --------------------------------------------------------
// Event-Binding
// --------------------------------------------------------
-function bindEvents(container, user, categories, icsSubscriptions) {
+function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
bindTabEvents(container);
bindCategoryEvents(container);
bindIcsEvents(container, user, icsSubscriptions);
+ bindApiTokenEvents(container, apiTokens);
// Theme-Toggle
const themeToggle = container.querySelector('#theme-toggle');
if (themeToggle) {
@@ -722,6 +755,109 @@ function bindDeleteButtons(container, user) {
});
}
+function apiTokenHtml(token) {
+ const status = token.revoked_at
+ ? t('settings.apiTokenRevoked')
+ : token.expires_at && new Date(token.expires_at).getTime() <= Date.now()
+ ? t('settings.apiTokenExpired')
+ : t('settings.apiTokenActive');
+ const meta = [
+ `${t('settings.apiTokenPrefix')}: ${token.token_prefix}...`,
+ token.expires_at ? `${t('settings.apiTokenExpires')}: ${formatDateTime(token.expires_at)}` : t('settings.apiTokenNeverExpires'),
+ token.last_used_at ? `${t('settings.apiTokenLastUsed')}: ${formatDateTime(token.last_used_at)}` : t('settings.apiTokenNeverUsed'),
+ status,
+ ].join(' · ');
+
+ return `
+
+
+ ${esc(token.name)}
+ ${esc(meta)}
+
+
+
+ `;
+}
+
+function renderApiTokenList(container, tokens) {
+ const list = container.querySelector('#api-token-list');
+ if (!list) return;
+ list.replaceChildren();
+ tokens.forEach((token) => {
+ const tmp = document.createElement('template');
+ tmp.innerHTML = apiTokenHtml(token);
+ list.appendChild(tmp.content.firstElementChild);
+ });
+ if (window.lucide) window.lucide.createIcons();
+}
+
+function datetimeLocalToIso(value) {
+ if (!value) return null;
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
+}
+
+function bindApiTokenEvents(container, initialTokens) {
+ const form = container.querySelector('#api-token-form');
+ const list = container.querySelector('#api-token-list');
+ if (!form || !list) return;
+
+ let tokens = [...initialTokens];
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = container.querySelector('#api-token-error');
+ const output = container.querySelector('#api-token-created');
+ const outputValue = container.querySelector('#api-token-created-value');
+ errorEl.hidden = true;
+ output.hidden = true;
+
+ const name = container.querySelector('#api-token-name').value.trim();
+ const expiresValue = container.querySelector('#api-token-expires').value;
+ const expires_at = datetimeLocalToIso(expiresValue);
+ if (expiresValue && !expires_at) {
+ showError(errorEl, t('settings.apiTokenInvalidExpiration'));
+ return;
+ }
+
+ const btn = form.querySelector('[type=submit]');
+ btn.disabled = true;
+ try {
+ const res = await api.post('/auth/api-tokens', { name, expires_at });
+ tokens.unshift(res.data);
+ renderApiTokenList(container, tokens);
+ form.reset();
+ outputValue.value = res.token;
+ output.hidden = false;
+ outputValue.focus();
+ outputValue.select();
+ window.oikos?.showToast(t('settings.apiTokenCreatedToast'), 'success');
+ } catch (err) {
+ showError(errorEl, err.message);
+ } finally {
+ btn.disabled = false;
+ }
+ });
+
+ list.addEventListener('click', async (e) => {
+ const btn = e.target.closest('[data-revoke-api-token]');
+ if (!btn) return;
+ const id = Number(btn.dataset.revokeApiToken);
+ const name = btn.dataset.name;
+ if (!await confirmModal(t('settings.apiTokenRevokeConfirm', { name }), { danger: true, confirmLabel: t('settings.apiTokenRevoke') })) return;
+ try {
+ await api.delete(`/auth/api-tokens/${id}`);
+ tokens = tokens.map((token) => token.id === id ? { ...token, revoked_at: new Date().toISOString() } : token);
+ renderApiTokenList(container, tokens);
+ window.oikos?.showToast(t('settings.apiTokenRevokedToast'), 'default');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+}
+
// --------------------------------------------------------
// Kategorie-Verwaltung
diff --git a/public/styles/settings.css b/public/styles/settings.css
index b66fecd..7bc7704 100644
--- a/public/styles/settings.css
+++ b/public/styles/settings.css
@@ -321,6 +321,13 @@
width: 100%;
}
+.settings-token-output {
+ padding: var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface-2);
+}
+
/* --------------------------------------------------------
Theme-Toggle
-------------------------------------------------------- */
diff --git a/server/auth.js b/server/auth.js
index 31a62ec..421c7da 100644
--- a/server/auth.js
+++ b/server/auth.js
@@ -8,12 +8,14 @@ import express from 'express';
import bcrypt from 'bcrypt';
import session from 'express-session';
import rateLimit from 'express-rate-limit';
+import crypto from 'node:crypto';
import * as db from './db.js';
import { generateToken, csrfMiddleware } from './middleware/csrf.js';
import { createLogger } from './logger.js';
const log = createLogger('Auth');
const router = express.Router();
+const API_TOKEN_PREFIX = 'oikos_';
// --------------------------------------------------------
// Session-Store (better-sqlite3, gleiche DB-Instanz wie App)
@@ -124,6 +126,60 @@ const loginLimiter = rateLimit({
message: { error: 'Zu viele Login-Versuche. Bitte warte kurz.', code: 429 },
});
+function hashApiToken(token) {
+ return crypto.createHash('sha256').update(token, 'utf8').digest('hex');
+}
+
+function extractApiToken(req) {
+ const auth = req.headers.authorization || '';
+ if (auth.toLowerCase().startsWith('bearer ')) return auth.slice(7).trim();
+ return String(req.headers['x-api-key'] || '').trim();
+}
+
+function publicApiToken(row) {
+ return {
+ id: row.id,
+ name: row.name,
+ token_prefix: row.token_prefix,
+ created_by: row.created_by,
+ creator_name: row.creator_name,
+ expires_at: row.expires_at,
+ revoked_at: row.revoked_at,
+ last_used_at: row.last_used_at,
+ created_at: row.created_at,
+ };
+}
+
+function authenticateApiToken(req) {
+ const token = extractApiToken(req);
+ if (!token) return null;
+
+ const tokenHash = hashApiToken(token);
+ const row = db.get().prepare(`
+ SELECT t.*, u.role, u.username, u.display_name, u.avatar_color
+ FROM api_tokens t
+ JOIN users u ON u.id = t.created_by
+ WHERE t.token_hash = ?
+ AND t.revoked_at IS NULL
+ AND (t.expires_at IS NULL OR t.expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ `).get(tokenHash);
+ if (!row) return null;
+
+ db.get().prepare(`
+ UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?
+ `).run(row.id);
+
+ req.apiToken = publicApiToken(row);
+ req.user = {
+ id: row.created_by,
+ username: row.username,
+ display_name: row.display_name,
+ avatar_color: row.avatar_color,
+ role: row.role,
+ };
+ return row;
+}
+
// --------------------------------------------------------
// Auth-Guard Middleware
// --------------------------------------------------------
@@ -133,7 +189,18 @@ const loginLimiter = rateLimit({
* Schützt alle API-Routen außer /auth/login.
*/
function requireAuth(req, res, next) {
+ const apiToken = authenticateApiToken(req);
+ if (apiToken) {
+ req.authMethod = 'api_token';
+ req.session = {
+ userId: apiToken.created_by,
+ role: apiToken.role,
+ };
+ return next();
+ }
+
if (req.session && req.session.userId) {
+ req.authMethod = 'session';
return next();
}
res.status(401).json({ error: 'Not authenticated.', code: 401 });
@@ -225,6 +292,9 @@ router.post('/login', loginLimiter, async (req, res) => {
* Response: { ok: true }
*/
router.post('/logout', requireAuth, csrfMiddleware, (req, res) => {
+ if (req.authMethod === 'api_token') {
+ return res.json({ ok: true });
+ }
req.session.destroy((err) => {
if (err) {
log.error('Logout error:', err);
@@ -300,6 +370,10 @@ router.get('/me', requireAuth, (req, res) => {
return res.status(401).json({ error: 'User not found.', code: 401 });
}
+ if (req.authMethod === 'api_token') {
+ return res.json({ user });
+ }
+
// CSRF-Token erneuern falls vorhanden (wichtig fuer iOS-PWA-Resume:
// iOS kann den CSRF-Cookie verwerfen waehrend die Session-Cookie erhalten bleibt.
// /me ist der erste API-Call nach App-Resume, also hier den Cookie wiederherstellen.)
@@ -337,6 +411,78 @@ router.get('/users', requireAuth, requireAdmin, (req, res) => {
}
});
+router.get('/api-tokens', requireAuth, requireAdmin, (req, res) => {
+ try {
+ const rows = db.get().prepare(`
+ SELECT t.*, u.display_name AS creator_name
+ FROM api_tokens t
+ LEFT JOIN users u ON u.id = t.created_by
+ ORDER BY t.created_at DESC
+ `).all();
+ res.json({ data: rows.map(publicApiToken) });
+ } catch (err) {
+ log.error('API token list error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
+ }
+});
+
+router.post('/api-tokens', requireAuth, requireAdmin, csrfMiddleware, (req, res) => {
+ try {
+ const name = String(req.body.name || '').trim();
+ const expiresAt = req.body.expires_at ? String(req.body.expires_at).trim() : null;
+
+ if (!name) return res.status(400).json({ error: 'Token name is required.', code: 400 });
+ if (name.length > 100) return res.status(400).json({ error: 'Token name may be at most 100 characters long.', code: 400 });
+ if (expiresAt && Number.isNaN(Date.parse(expiresAt))) {
+ return res.status(400).json({ error: 'expires_at must be a valid ISO date/time.', code: 400 });
+ }
+ if (expiresAt && new Date(expiresAt).getTime() <= Date.now()) {
+ return res.status(400).json({ error: 'Expiration date must be in the future.', code: 400 });
+ }
+
+ const token = API_TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url');
+ const tokenHash = hashApiToken(token);
+ const tokenPrefix = token.slice(0, 12);
+ const normalizedExpiresAt = expiresAt ? new Date(expiresAt).toISOString() : null;
+
+ const result = db.get().prepare(`
+ INSERT INTO api_tokens (name, token_hash, token_prefix, created_by, expires_at)
+ VALUES (?, ?, ?, ?, ?)
+ `).run(name, tokenHash, tokenPrefix, req.session.userId, normalizedExpiresAt);
+
+ const row = db.get().prepare(`
+ SELECT t.*, u.display_name AS creator_name
+ FROM api_tokens t
+ LEFT JOIN users u ON u.id = t.created_by
+ WHERE t.id = ?
+ `).get(result.lastInsertRowid);
+
+ res.status(201).json({ data: publicApiToken(row), token });
+ } catch (err) {
+ log.error('API token creation error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
+ }
+});
+
+router.delete('/api-tokens/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res) => {
+ try {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid token ID.', code: 400 });
+
+ const result = db.get().prepare(`
+ UPDATE api_tokens
+ SET revoked_at = COALESCE(revoked_at, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ WHERE id = ?
+ `).run(id);
+
+ if (result.changes === 0) return res.status(404).json({ error: 'API token not found.', code: 404 });
+ res.json({ ok: true });
+ } catch (err) {
+ log.error('API token revocation error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
+ }
+});
+
/**
* POST /api/v1/auth/users
* Admin only. Erstellt neues Familienmitglied.
diff --git a/server/db-schema-test.js b/server/db-schema-test.js
index e34e67c..981dde1 100644
--- a/server/db-schema-test.js
+++ b/server/db-schema-test.js
@@ -144,6 +144,17 @@ const MIGRATIONS_SQL = {
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(category_key, name)
);
+ CREATE TABLE IF NOT EXISTS api_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ token_prefix TEXT NOT NULL,
+ created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at TEXT,
+ revoked_at TEXT,
+ last_used_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
AFTER UPDATE ON users FOR EACH ROW
BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
@@ -185,6 +196,8 @@ const MIGRATIONS_SQL = {
CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned);
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
`,
2: `
CREATE TABLE IF NOT EXISTS sync_config (
diff --git a/server/db.js b/server/db.js
index 05d6f8c..78ff0eb 100644
--- a/server/db.js
+++ b/server/db.js
@@ -671,6 +671,26 @@ const MIGRATIONS = [
GROUP BY category, subcategory;
`,
},
+ {
+ version: 17,
+ description: 'API tokens for non-interactive authentication',
+ up: `
+ CREATE TABLE IF NOT EXISTS api_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ token_prefix TEXT NOT NULL,
+ created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at TEXT,
+ revoked_at TEXT,
+ last_used_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
+ `,
+ },
];
/**
diff --git a/server/middleware/csrf.js b/server/middleware/csrf.js
index 82ea63b..35f301d 100644
--- a/server/middleware/csrf.js
+++ b/server/middleware/csrf.js
@@ -28,6 +28,8 @@ function generateToken() {
* Muss NACH requireAuth eingebunden werden.
*/
function csrfMiddleware(req, res, next) {
+ if (req.authMethod === 'api_token') return next();
+
// Token generieren falls noch nicht vorhanden (erste Request nach Login)
if (!req.session.csrfToken) {
req.session.csrfToken = generateToken();
diff --git a/test-db.js b/test-db.js
index ac11f83..0ab8a51 100644
--- a/test-db.js
+++ b/test-db.js
@@ -74,6 +74,7 @@ const EXPECTED_TABLES = [
'users', 'tasks', 'shopping_lists', 'shopping_items',
'meals', 'meal_ingredients', 'calendar_events',
'notes', 'contacts', 'budget_entries',
+ 'budget_categories', 'budget_subcategories', 'api_tokens',
];
EXPECTED_TABLES.forEach((table) => {
@@ -88,9 +89,18 @@ EXPECTED_TABLES.forEach((table) => {
// --------------------------------------------------------
// Test 4: Alle updated_at-Triggers vorhanden
// --------------------------------------------------------
-const EXPECTED_TRIGGERS = EXPECTED_TABLES.filter((t) => t !== 'schema_migrations').map(
- (t) => `trg_${t}_updated_at`
-);
+const EXPECTED_TRIGGERS = [
+ 'users',
+ 'tasks',
+ 'shopping_lists',
+ 'shopping_items',
+ 'meals',
+ 'meal_ingredients',
+ 'calendar_events',
+ 'notes',
+ 'contacts',
+ 'budget_entries',
+].map((t) => `trg_${t}_updated_at`);
EXPECTED_TRIGGERS.forEach((trigger) => {
test(`Trigger "${trigger}" existiert`, () => {
@@ -179,6 +189,17 @@ test('Idempotenz: Migration zweimal ausführen ändert nichts', () => {
assert(tables.n > 0, 'Tabellen sollten noch vorhanden sein');
});
+test('API-Token anlegen und lesen', () => {
+ const result = db.prepare(`
+ INSERT INTO api_tokens (name, token_hash, token_prefix, created_by, expires_at)
+ VALUES ('MCP integration', 'hash-123', 'oikos_abc123', 1, '2026-12-31T23:59:59.000Z')
+ `).run();
+ const token = db.prepare('SELECT * FROM api_tokens WHERE id = ?').get(result.lastInsertRowid);
+ assert(token.name === 'MCP integration', 'Token name stimmt nicht');
+ assert(token.created_by === 1, 'Token creator stimmt nicht');
+ assert(token.revoked_at === null, 'Token sollte nicht widerrufen sein');
+});
+
// --------------------------------------------------------
// Ergebnis
// --------------------------------------------------------