diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts index b820a79..5987914 100644 --- a/server/src/db/connection.ts +++ b/server/src/db/connection.ts @@ -164,6 +164,7 @@ const runMigrations = () => { pin TEXT PRIMARY KEY, host_peer_id TEXT NOT NULL, host_secret TEXT NOT NULL, + host_user_id TEXT, quiz_data TEXT NOT NULL, game_config TEXT NOT NULL, game_state TEXT NOT NULL DEFAULT 'LOBBY', @@ -185,6 +186,12 @@ const runMigrations = () => { console.log("Migration: Added first_correct_player_id to game_sessions"); } + const hasHostUserId = sessionTableInfo.some(col => col.name === "host_user_id"); + if (!hasHostUserId) { + db.exec("ALTER TABLE game_sessions ADD COLUMN host_user_id TEXT"); + console.log("Migration: Added host_user_id to game_sessions"); + } + const hasColorScheme = userTableInfo2.some(col => col.name === "color_scheme"); if (!hasColorScheme) { db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'"); diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index 070cdf9..9d3e3c0 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS game_sessions ( pin TEXT PRIMARY KEY, host_peer_id TEXT NOT NULL, host_secret TEXT NOT NULL, + host_user_id TEXT, quiz_data TEXT NOT NULL, game_config TEXT NOT NULL, game_state TEXT NOT NULL DEFAULT 'LOBBY', diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index da2fc5b..c0a2d8f 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -42,6 +42,53 @@ export interface AuthenticatedRequest extends Request { user?: AuthenticatedUser; } +export function optionalAuth( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + next(); + return; + } + + const token = authHeader.slice(7); + + const verifyOptions: jwt.VerifyOptions = { + issuer: OIDC_ISSUER, + algorithms: ['RS256'], + }; + if (OIDC_AUDIENCE) { + verifyOptions.audience = OIDC_AUDIENCE; + } + + jwt.verify( + token, + getSigningKey, + verifyOptions, + (err, decoded) => { + if (err) { + console.error('Optional auth - token verification failed:', err.message); + next(); + return; + } + + const payload = decoded as jwt.JwtPayload; + req.user = { + sub: payload.sub!, + preferred_username: payload.preferred_username || payload.sub!, + email: payload.email, + name: payload.name, + groups: payload.groups || [], + }; + + next(); + } + ); +} + export function requireAuth( req: AuthenticatedRequest, res: Response, diff --git a/server/src/routes/games.ts b/server/src/routes/games.ts index 1978529..7ef2c45 100644 --- a/server/src/routes/games.ts +++ b/server/src/routes/games.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express'; import rateLimit from 'express-rate-limit'; import { db } from '../db/connection.js'; import { randomBytes } from 'crypto'; +import { optionalAuth, AuthenticatedRequest } from '../middleware/auth.js'; const router = Router(); @@ -31,6 +32,7 @@ interface GameSession { pin: string; host_peer_id: string; host_secret: string; + host_user_id: string | null; quiz_data: string; game_config: string; game_state: string; @@ -55,7 +57,7 @@ const cleanupExpiredSessions = () => { setInterval(cleanupExpiredSessions, 60 * 1000); -router.post('/', gameCreationLimiter, (req: Request, res: Response) => { +router.post('/', gameCreationLimiter, optionalAuth, (req: AuthenticatedRequest, res: Response) => { try { const { pin, hostPeerId, quiz, gameConfig } = req.body; @@ -65,11 +67,12 @@ router.post('/', gameCreationLimiter, (req: Request, res: Response) => { } const hostSecret = randomBytes(32).toString('hex'); + const hostUserId = req.user?.sub || null; db.prepare(` - INSERT INTO game_sessions (pin, host_peer_id, host_secret, quiz_data, game_config, game_state, players_data) - VALUES (?, ?, ?, ?, ?, 'LOBBY', '[]') - `).run(pin, hostPeerId, hostSecret, JSON.stringify(quiz), JSON.stringify(gameConfig)); + INSERT INTO game_sessions (pin, host_peer_id, host_secret, host_user_id, quiz_data, game_config, game_state, players_data) + VALUES (?, ?, ?, ?, ?, ?, 'LOBBY', '[]') + `).run(pin, hostPeerId, hostSecret, hostUserId, JSON.stringify(quiz), JSON.stringify(gameConfig)); res.status(201).json({ success: true, hostSecret }); } catch (err: any) { @@ -111,20 +114,29 @@ router.get('/:pin', gameLookupLimiter, (req: Request, res: Response) => { } }); -router.get('/:pin/host', (req: Request, res: Response) => { +router.get('/:pin/host', optionalAuth, (req: AuthenticatedRequest, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; + const hostUserId = req.user?.sub; - if (!hostSecret) { - res.status(401).json({ error: 'Host secret required' }); + if (!hostSecret && !hostUserId) { + res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' }); return; } - const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret) as GameSession | undefined; + const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined; if (!session) { - res.status(404).json({ error: 'Game not found or invalid secret' }); + res.status(404).json({ error: 'Game not found' }); + return; + } + + const isValidSecret = hostSecret && session.host_secret === hostSecret; + const isValidUser = hostUserId && session.host_user_id === hostUserId; + + if (!isValidSecret && !isValidUser) { + res.status(404).json({ error: 'Game not found or invalid credentials' }); return; } @@ -144,21 +156,30 @@ router.get('/:pin/host', (req: Request, res: Response) => { } }); -router.patch('/:pin', (req: Request, res: Response) => { +router.patch('/:pin', optionalAuth, (req: AuthenticatedRequest, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; + const hostUserId = req.user?.sub; const { hostPeerId, gameState, currentQuestionIndex, players, firstCorrectPlayerId } = req.body; - if (!hostSecret) { - res.status(401).json({ error: 'Host secret required' }); + if (!hostSecret && !hostUserId) { + res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' }); return; } - const session = db.prepare('SELECT pin FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret); + const session = db.prepare('SELECT pin, host_secret, host_user_id FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined; if (!session) { - res.status(404).json({ error: 'Game not found or invalid secret' }); + res.status(404).json({ error: 'Game not found' }); + return; + } + + const isValidSecret = hostSecret && session.host_secret === hostSecret; + const isValidUser = hostUserId && session.host_user_id === hostUserId; + + if (!isValidSecret && !isValidUser) { + res.status(404).json({ error: 'Game not found or invalid credentials' }); return; } @@ -192,12 +213,12 @@ router.patch('/:pin', (req: Request, res: Response) => { } updates.push('updated_at = CURRENT_TIMESTAMP'); - values.push(pin, hostSecret); + values.push(pin); db.prepare(` UPDATE game_sessions SET ${updates.join(', ')} - WHERE pin = ? AND host_secret = ? + WHERE pin = ? `).run(...values); res.json({ success: true }); @@ -207,23 +228,34 @@ router.patch('/:pin', (req: Request, res: Response) => { } }); -router.delete('/:pin', (req: Request, res: Response) => { +router.delete('/:pin', optionalAuth, (req: AuthenticatedRequest, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; + const hostUserId = req.user?.sub; - if (!hostSecret) { - res.status(401).json({ error: 'Host secret required' }); + if (!hostSecret && !hostUserId) { + res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' }); return; } - const result = db.prepare('DELETE FROM game_sessions WHERE pin = ? AND host_secret = ?').run(pin, hostSecret); + const session = db.prepare('SELECT pin, host_secret, host_user_id FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined; - if (result.changes === 0) { - res.status(404).json({ error: 'Game not found or invalid secret' }); + if (!session) { + res.status(404).json({ error: 'Game not found' }); return; } + const isValidSecret = hostSecret && session.host_secret === hostSecret; + const isValidUser = hostUserId && session.host_user_id === hostUserId; + + if (!isValidSecret && !isValidUser) { + res.status(404).json({ error: 'Game not found or invalid credentials' }); + return; + } + + db.prepare('DELETE FROM game_sessions WHERE pin = ?').run(pin); + res.json({ success: true }); } catch (err) { console.error('Error deleting game session:', err); diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index a1de080..2df6fb9 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -1994,6 +1994,118 @@ console.log('\n=== Game Session Tests ==='); }); }); + console.log('\nHost Proof Validation Tests:'); + + let authGamePin: string | null = null; + + await test('POST /api/games with Authorization token stores host_user_id', async () => { + const gameData = { + pin: '777777', + hostPeerId: 'kaboot-777777', + quiz: { title: 'Auth Test Quiz', questions: [] }, + gameConfig: {}, + }; + + const { data } = await gameRequest('POST', '/api/games', gameData, { + 'Authorization': `Bearer ${TOKEN}`, + }, 201); + + if (!(data as { success: boolean }).success) throw new Error('Expected success: true'); + authGamePin = '777777'; + }); + + await test('GET /api/games/:pin/host with valid Authorization token succeeds', async () => { + if (!authGamePin) throw new Error('No auth game created'); + + const { data } = await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, { + 'Authorization': `Bearer ${TOKEN}`, + }); + + const game = data as Record; + if (game.pin !== authGamePin) throw new Error('Wrong PIN'); + if (!game.quiz) throw new Error('Missing quiz'); + }); + + await test('PATCH /api/games/:pin with valid Authorization token succeeds', async () => { + if (!authGamePin) throw new Error('No auth game created'); + + await gameRequest('PATCH', `/api/games/${authGamePin}`, { + gameState: 'QUESTION', + }, { + 'Authorization': `Bearer ${TOKEN}`, + }); + + const { data } = await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, { + 'Authorization': `Bearer ${TOKEN}`, + }); + + const game = data as Record; + if (game.gameState !== 'QUESTION') throw new Error('gameState not updated'); + }); + + await test('GET /api/games/:pin/host with neither secret nor token returns 401', async () => { + if (!authGamePin) throw new Error('No auth game created'); + await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, {}, 401); + }); + + await test('PATCH /api/games/:pin with neither secret nor token returns 401', async () => { + if (!authGamePin) throw new Error('No auth game created'); + await gameRequest('PATCH', `/api/games/${authGamePin}`, { gameState: 'LOBBY' }, {}, 401); + }); + + await test('DELETE /api/games/:pin with neither secret nor token returns 401', async () => { + if (!authGamePin) throw new Error('No auth game created'); + await gameRequest('DELETE', `/api/games/${authGamePin}`, undefined, {}, 401); + }); + + await test('GET /api/games/:pin/host with invalid token returns 401', async () => { + if (!authGamePin) throw new Error('No auth game created'); + await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, { + 'Authorization': 'Bearer invalid-token-12345', + }, 401); + }); + + await test('PATCH /api/games/:pin with invalid token returns 401', async () => { + if (!authGamePin) throw new Error('No auth game created'); + await gameRequest('PATCH', `/api/games/${authGamePin}`, { gameState: 'LOBBY' }, { + 'Authorization': 'Bearer invalid-token-12345', + }, 401); + }); + + await test('DELETE /api/games/:pin with valid Authorization token succeeds', async () => { + if (!authGamePin) throw new Error('No auth game created'); + + await gameRequest('DELETE', `/api/games/${authGamePin}`, undefined, { + 'Authorization': `Bearer ${TOKEN}`, + }); + + await gameRequest('GET', `/api/games/${authGamePin}`, undefined, {}, 404); + }); + + await test('POST /api/games without auth allows access via secret only', async () => { + const gameData = { + pin: '888888', + hostPeerId: 'kaboot-888888', + quiz: { title: 'No Auth Quiz', questions: [] }, + gameConfig: {}, + }; + + const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201); + const secret = (data as { hostSecret: string }).hostSecret; + + await gameRequest('GET', `/api/games/888888/host`, undefined, { + 'X-Host-Secret': secret, + }); + + await gameRequest('GET', `/api/games/888888/host`, undefined, { + 'Authorization': `Bearer ${TOKEN}`, + }, 404); + + await gameRequest('DELETE', '/api/games/888888', undefined, { + 'X-Host-Secret': secret, + }); + }); + console.log('\n=== AI Generate Endpoint Tests ==='); console.log('\nGenerate Status Tests:');