feat: add Steam OpenID login
All checks were successful
Build & Deploy / build (push) Successful in 47s
Build & Deploy / deploy (push) Successful in 4s
Build & Deploy / bump-version (push) Successful in 2s

- Add Steam OpenID 2.0 authentication routes (login + callback)
- Enable Steam button in LoginModal (was placeholder)
- Unified user ID system: getUserId() supports Discord, Steam, Admin
- Update soundboard user-sound endpoints for Steam users
- UserSettings now works for both Discord and Steam providers
- Steam hover uses brand color #66c0f4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-10 22:18:37 +01:00
parent aa998c9b44
commit d135aab6dc
6 changed files with 162 additions and 38 deletions

View file

@ -1,5 +1,5 @@
// ──────────────────────────────────────────────────────────────────────────────
// Discord OAuth2 Authentication + Unified Session Management
// Unified Authentication: Discord OAuth2, Steam OpenID 2.0, Admin
// ──────────────────────────────────────────────────────────────────────────────
import crypto from 'node:crypto';
import type express from 'express';
@ -10,6 +10,7 @@ const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET ?? '';
const DISCORD_API = 'https://discord.com/api/v10';
const DISCORD_AUTH_URL = 'https://discord.com/oauth2/authorize';
const DISCORD_TOKEN_URL = `${DISCORD_API}/oauth2/token`;
const STEAM_API_KEY = process.env.STEAM_API_KEY ?? '';
const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 days in seconds
const ADMIN_MAX_AGE = 7 * 24 * 3600; // 7 days in seconds
@ -23,8 +24,9 @@ export interface DiscordUser {
}
export interface UserSession {
provider: 'discord' | 'admin';
provider: 'discord' | 'steam' | 'admin';
discordId?: string;
steamId?: string;
username?: string;
avatar?: string | null;
globalName?: string | null;
@ -32,6 +34,14 @@ export interface UserSession {
exp: number;
}
/** Returns the generic user ID regardless of provider (discordId, steam:steamId, or 'admin') */
export function getUserId(session: UserSession): string | null {
if (session.discordId) return session.discordId;
if (session.steamId) return `steam:${session.steamId}`;
if (session.provider === 'admin') return 'admin';
return null;
}
// ── Helpers ──
function b64url(input: Buffer | string): string {
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
@ -135,6 +145,55 @@ async function fetchDiscordUser(accessToken: string): Promise<DiscordUser> {
return await res.json() as DiscordUser;
}
// ── Steam OpenID 2.0 ──
export function isSteamConfigured(): boolean {
return !!STEAM_API_KEY;
}
function getSteamReturnUrl(req: express.Request): string {
const publicUrl = process.env.PUBLIC_URL ?? '';
if (publicUrl) return `${publicUrl.replace(/\/$/, '')}/api/auth/steam/callback`;
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
return `${proto}://${host}/api/auth/steam/callback`;
}
function getSteamRealm(req: express.Request): string {
const publicUrl = process.env.PUBLIC_URL ?? '';
if (publicUrl) return publicUrl.replace(/\/$/, '');
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
return `${proto}://${host}`;
}
async function verifySteamOpenId(query: Record<string, string>): Promise<string | null> {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
params.set(key, String(value));
}
params.set('openid.mode', 'check_authentication');
const resp = await fetch(`https://steamcommunity.com/openid/login?${params.toString()}`);
const text = await resp.text();
if (!text.includes('is_valid:true')) return null;
const claimedId = String(query['openid.claimed_id'] || '');
const match = claimedId.match(/\/id\/(\d+)$/);
return match ? match[1] : null;
}
async function fetchSteamProfile(steamId: string): Promise<{ personaName: string; avatarUrl: string }> {
const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${STEAM_API_KEY}&steamids=${steamId}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`Steam API error: ${resp.status}`);
const json = await resp.json() as any;
const player = json?.response?.players?.[0];
return {
personaName: player?.personaname || steamId,
avatarUrl: player?.avatarfull || '',
};
}
// ── Register Routes ──
export function registerAuthRoutes(app: express.Application, adminPwd: string): void {
@ -142,7 +201,7 @@ export function registerAuthRoutes(app: express.Application, adminPwd: string):
app.get('/api/auth/providers', (_req, res) => {
res.json({
discord: isDiscordConfigured(),
steam: false, // Steam OpenID — planned
steam: isSteamConfigured(),
admin: !!adminPwd,
});
});
@ -158,6 +217,7 @@ export function registerAuthRoutes(app: express.Application, adminPwd: string):
authenticated: true,
provider: session.provider,
discordId: session.discordId ?? null,
steamId: session.steamId ?? null,
username: session.username ?? null,
avatar: session.avatar ?? null,
globalName: session.globalName ?? null,
@ -211,6 +271,58 @@ export function registerAuthRoutes(app: express.Application, adminPwd: string):
}
});
// Steam OpenID 2.0 — start
app.get('/api/auth/steam', (req, res) => {
if (!isSteamConfigured()) {
res.status(503).json({ error: 'Steam nicht konfiguriert (STEAM_API_KEY fehlt)' });
return;
}
const realm = getSteamRealm(req);
const returnTo = getSteamReturnUrl(req);
const params = new URLSearchParams({
'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.mode': 'checkid_setup',
'openid.return_to': returnTo,
'openid.realm': realm,
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
});
console.log(`[Auth] Steam OpenID redirect → ${returnTo}`);
res.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`);
});
// Steam OpenID 2.0 — callback
app.get('/api/auth/steam/callback', async (req, res) => {
try {
const steamId = await verifySteamOpenId(req.query as Record<string, string>);
if (!steamId) {
res.status(403).send('Steam-Verifizierung fehlgeschlagen.');
return;
}
const profile = await fetchSteamProfile(steamId);
const session: UserSession = {
provider: 'steam',
steamId,
username: profile.personaName,
avatar: profile.avatarUrl || null,
iat: Date.now(),
exp: Date.now() + SESSION_MAX_AGE * 1000,
};
const token = signSession(session);
res.setHeader('Set-Cookie', `hub_session=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${SESSION_MAX_AGE}; SameSite=Lax`);
console.log(`[Auth] Steam login: ${profile.personaName} (${steamId})`);
res.redirect('/');
} catch (e) {
console.error('[Auth] Steam callback error:', e);
res.status(500).send('Steam Login fehlgeschlagen. Bitte erneut versuchen.');
}
});
// Admin login (via unified modal)
app.post('/api/auth/admin', (req, res) => {
if (!adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; }

View file

@ -17,7 +17,7 @@ import nacl from 'tweetnacl';
import { ChannelType, Events, type VoiceBasedChannel, type VoiceState, type Message } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js';
import { getSession } from '../../core/discord-auth.js';
import { getSession, getUserId } from '../../core/discord-auth.js';
// ── Config (env) ──
const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
@ -1245,15 +1245,15 @@ const soundboardPlugin: Plugin = {
});
});
// ── User Sound Preferences (Discord-authenticated) ──
// ── User Sound Preferences (Discord / Steam authenticated) ──
// Get current user's entrance/exit sounds
app.get('/api/soundboard/user/sounds', (req, res) => {
const session = getSession(req);
if (!session?.discordId) {
const userId = session ? getUserId(session) : null;
if (!userId) {
res.status(401).json({ error: 'Nicht eingeloggt' });
return;
}
const userId = session.discordId;
const entrance = persistedState.entranceSounds?.[userId] ?? null;
const exit = persistedState.exitSounds?.[userId] ?? null;
res.json({ entrance, exit });
@ -1262,7 +1262,8 @@ const soundboardPlugin: Plugin = {
// Set entrance sound
app.post('/api/soundboard/user/entrance', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
const userId = session ? getUserId(session) : null;
if (!userId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
const { fileName } = req.body ?? {};
if (!fileName || typeof fileName !== 'string') { res.status(400).json({ error: 'fileName erforderlich' }); return; }
if (!/\.(mp3|wav)$/i.test(fileName)) { res.status(400).json({ error: 'Nur .mp3 oder .wav' }); return; }
@ -1279,16 +1280,17 @@ const soundboardPlugin: Plugin = {
})();
if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; }
persistedState.entranceSounds = persistedState.entranceSounds ?? {};
persistedState.entranceSounds[session.discordId] = resolve;
persistedState.entranceSounds[userId] = resolve;
writeState();
console.log(`[Soundboard] User ${session.username} (${session.discordId}) set entrance: ${resolve}`);
console.log(`[Soundboard] User ${session!.username} (${userId}) set entrance: ${resolve}`);
res.json({ ok: true, entrance: resolve });
});
// Set exit sound
app.post('/api/soundboard/user/exit', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
const userId = session ? getUserId(session) : null;
if (!userId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
const { fileName } = req.body ?? {};
if (!fileName || typeof fileName !== 'string') { res.status(400).json({ error: 'fileName erforderlich' }); return; }
if (!/\.(mp3|wav)$/i.test(fileName)) { res.status(400).json({ error: 'Nur .mp3 oder .wav' }); return; }
@ -1304,33 +1306,35 @@ const soundboardPlugin: Plugin = {
})();
if (!resolve) { res.status(404).json({ error: 'Datei nicht gefunden' }); return; }
persistedState.exitSounds = persistedState.exitSounds ?? {};
persistedState.exitSounds[session.discordId] = resolve;
persistedState.exitSounds[userId] = resolve;
writeState();
console.log(`[Soundboard] User ${session.username} (${session.discordId}) set exit: ${resolve}`);
console.log(`[Soundboard] User ${session!.username} (${userId}) set exit: ${resolve}`);
res.json({ ok: true, exit: resolve });
});
// Remove entrance sound
app.delete('/api/soundboard/user/entrance', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
const userId = session ? getUserId(session) : null;
if (!userId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
if (persistedState.entranceSounds) {
delete persistedState.entranceSounds[session.discordId];
delete persistedState.entranceSounds[userId];
writeState();
}
console.log(`[Soundboard] User ${session.username} (${session.discordId}) removed entrance sound`);
console.log(`[Soundboard] User ${session!.username} (${userId}) removed entrance sound`);
res.json({ ok: true });
});
// Remove exit sound
app.delete('/api/soundboard/user/exit', (req, res) => {
const session = getSession(req);
if (!session?.discordId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
const userId = session ? getUserId(session) : null;
if (!userId) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; }
if (persistedState.exitSounds) {
delete persistedState.exitSounds[session.discordId];
delete persistedState.exitSounds[userId];
writeState();
}
console.log(`[Soundboard] User ${session.username} (${session.discordId}) removed exit sound`);
console.log(`[Soundboard] User ${session!.username} (${userId}) removed exit sound`);
res.json({ ok: true });
});