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
+74
View File
@@ -161,6 +161,10 @@ export default function Settings({ me }) {
const [botDesc, setBotDesc] = useState('');
const [team, setTeam] = useState([]);
const [audit, setAudit] = useState([]);
// SSO-Apps (Fremd-Dienste mit Discord-Login)
const [ssoApps, setSsoApps] = useState([]);
const [ssoDraft, setSsoDraft] = useState({ slug: '', name: '', redirect_prefix: '' });
const [createdSso, setCreatedSso] = useState(null);
const [teamDraft, setTeamDraft] = useState({ user_id: '', scopes: [] });
const canTab = (id) => {
@@ -203,6 +207,7 @@ export default function Settings({ me }) {
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
}, [me.admin, me.scopes?.length]);
useEffect(() => {
@@ -236,6 +241,28 @@ export default function Settings({ me }) {
if (res) setTeam(res.admins);
}
async function createSsoApp() {
if (!ssoDraft.slug.trim() || !ssoDraft.name.trim() || !ssoDraft.redirect_prefix.trim()) {
flash('✗ Kürzel, Name und Rücksprung-URL nötig');
return;
}
try {
const res = await apiPost('/api/ssoapps', ssoDraft);
setCreatedSso(res);
setSsoDraft({ slug: '', name: '', redirect_prefix: '' });
setSsoApps((await apiGet('/api/ssoapps')).apps);
flash('✓ App registriert');
} catch (e) {
flash(e.status === 409 ? '✗ Kürzel schon vergeben' : '✗ Registrierung fehlgeschlagen');
}
}
async function removeSsoApp(slug) {
if (!window.confirm(`App „${slug}" entfernen? Der Login über diese App funktioniert danach nicht mehr.`)) return;
const res = await apiDelete(`/api/ssoapps/${slug}`).catch(() => null);
if (res) setSsoApps(res.apps);
}
const serverNeedsUrl = ['fivem', 'http'].includes(serverDraft.type);
async function saveServer() {
if (!serverDraft.name.trim() || (serverNeedsUrl ? !serverDraft.query_url.trim() : !serverDraft.host.trim())) {
@@ -1325,6 +1352,7 @@ export default function Settings({ me }) {
);
const tabApi = (
<>
<div className="settings-section">
<h2 className="settings-title">// API-Keys (/api/v1)</h2>
{keys.length > 0 && (
@@ -1373,6 +1401,52 @@ export default function Settings({ me }) {
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Single Sign-On</h2>
<p className="section-intro">
Andere Dienste (Kanban, Platform ) können den Discord-Login mitnutzen: Sie
leiten auf <code>/sso/authorize</code> weiter und tauschen den Token per
<code>POST /sso/verify</code> gegen Nutzerdaten samt Discord-Rollen ein.
</p>
{ssoApps.length > 0 && (
<div className="key-list">
{ssoApps.map((a) => (
<div className="key-row" key={a.slug}>
<span className="key-name">{a.name}</span>
<span className="key-scopes">{a.slug}</span>
<span className="key-used" title={a.redirect_prefix}>
{a.last_used ? `zuletzt: ${a.last_used.slice(0, 16)}` : 'nie benutzt'}
</span>
<button className="card-delete" title="App entfernen" onClick={() => removeSsoApp(a.slug)}></button>
</div>
))}
</div>
)}
{createdSso && (
<div className="key-created">
<p>
App <strong>{createdSso.name}</strong> registriert Secret <strong>jetzt kopieren</strong>,
es wird nie wieder angezeigt:
</p>
<code>{createdSso.secret}</code>
</div>
)}
<div className="field">
<label>Neue App <span className="field-hint">Kürzel, Anzeigename und erlaubte Rücksprung-URL(s)</span></label>
<div className="field-row">
<input type="text" style={{ flex: 'none', width: '9rem' }} placeholder="kanban" value={ssoDraft.slug} onChange={(e) => setSsoDraft({ ...ssoDraft, slug: e.target.value })} />
<input type="text" style={{ flex: 'none', width: '11rem' }} placeholder="Kanban-Board" value={ssoDraft.name} onChange={(e) => setSsoDraft({ ...ssoDraft, name: e.target.value })} />
<input type="text" placeholder="https://kanban.d4rkst3r.de/" value={ssoDraft.redirect_prefix} onChange={(e) => setSsoDraft({ ...ssoDraft, redirect_prefix: e.target.value })} />
<button className="btn" onClick={createSsoApp}>Registrieren</button>
</div>
<p className="field-hint" style={{ marginTop: '.4rem' }}>
Mehrere URLs kommagetrennt. Der Bot leitet nur auf Ziele zurück, die mit
einem dieser Präfixe beginnen.
</p>
</div>
</div>
</>
);
const tabServer = (