Single Sign-On: der Bot als Identity-Provider für die anderen Dienste

Andere Apps (Kanban, Platform …) nutzen ab sofort den Discord-Login des
Bots mit, statt jeweils eigenes OAuth zu bauen — und bekommen die
Discord-Rollen des Users gleich mitgeliefert.

Ablauf: App leitet auf /sso/authorize weiter, der Bot prüft die Session
(ggf. erst Discord-Login) und schickt einen signierten Token zurück, den
die App serverseitig per POST /sso/verify gegen die Nutzerdaten tauscht.

Sicherheit:
- Rücksprung-Ziele müssen einem registrierten Präfix entsprechen
  (kein Open Redirect, kein Token-Abgriff über fremde Hosts)
- Token HMAC-signiert, 60 Sekunden gültig, nur einmal einlösbar
- Verify braucht das App-Secret (timing-safe verglichen)
- SSO bleibt Mitgliedern des Discord-Servers vorbehalten
- App-Verwaltung ist Owner-only, Secret wird nur einmal angezeigt

Dazu: sso_apps-Tabelle, Verwaltung im API-Tab, Rücksprung nach dem
Login (return-Cookie, nur interne Pfade), Anleitung in docs/sso.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 13:24:03 +02:00
co-authored by Claude Fable 5
parent 8853bce2ec
commit 70cae85064
8 changed files with 413 additions and 2 deletions
+33
View File
@@ -15,7 +15,9 @@ import {
saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
logAudit, listAudit, playerHistory, deleteUserData,
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
} from '../db.js';
import crypto from 'node:crypto';
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js';
@@ -1310,6 +1312,37 @@ ${rssItems}
return { entries: listAudit(100) };
});
// --- SSO-Apps (nur Owner): Fremd-Dienste, die den Discord-Login mitnutzen ---
app.get('/api/ssoapps', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { apps: listSsoApps() };
});
app.post('/api/ssoapps', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const slug = String(request.body?.slug ?? '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
const name = String(request.body?.name ?? '').trim();
const prefix = String(request.body?.redirect_prefix ?? '').trim();
if (!slug || !name) return reply.code(400).send({ error: 'slug und name nötig' });
if (!prefix.split(/[\s,]+/).filter(Boolean).every((p) => /^https?:\/\/.+/.test(p))) {
return reply.code(400).send({ error: 'redirect_prefix: http(s)-URL(s) erwartet' });
}
if (getSsoApp(slug)) return reply.code(409).send({ error: 'slug schon vergeben' });
const secret = `sso_${crypto.randomBytes(24).toString('hex')}`;
saveSsoApp({ slug, name, redirect_prefix: prefix, secret });
logAudit(getSessionUser(request), 'sso-app angelegt', `${name} (${slug})`);
// Secret nur in dieser einen Antwort!
return { slug, name, redirect_prefix: prefix, secret };
});
app.delete('/api/ssoapps/:slug', async (request, reply) => {
if (requireAdmin(request, reply)) return;
logAudit(getSessionUser(request), 'sso-app gelöscht', String(request.params.slug));
return { deleted: deleteSsoApp(String(request.params.slug)), apps: listSsoApps() };
});
// --- API-Keys (Admin) — für /api/v1/* ---
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];