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
+3 -1
View File
@@ -16,6 +16,7 @@ import { registerAuthRoutes } from './auth.js';
import { registerApiRoutes } from './api.js';
import { registerApiV1 } from './api-v1.js';
import { registerDevlogEndpoint } from './devlog-endpoint.js';
import { registerSsoRoutes } from './sso.js';
import { imagesDir } from '../bot/devlog-archive.js';
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
@@ -67,6 +68,7 @@ export async function startWebServer(client) {
registerApiRoutes(app, client);
registerApiV1(app, client);
registerDevlogEndpoint(app, client);
registerSsoRoutes(app, client);
// Healthcheck (für Portainer/NPM)
app.get('/health', async () => ({ status: 'ok' }));
@@ -149,7 +151,7 @@ export async function startWebServer(client) {
await app.register(fastifyStatic, { root: frontendDist, index: false });
app.get('/', sendInjected);
app.setNotFoundHandler((request, reply) => {
const isApiPath = ['/api', '/auth', '/webhooks'].some((p) =>
const isApiPath = ['/api', '/auth', '/webhooks', '/sso'].some((p) =>
request.url.startsWith(p)
);
if (request.method === 'GET' && !isApiPath) {