diff --git a/.gitignore b/.gitignore index 8652c3158..84f2ae291 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ resources/images/.DS_Store resources/.DS_Store .env .prettierrc -.prettierignore \ No newline at end of file +.prettierignore +.DS_Store \ No newline at end of file diff --git a/src/client/index.html b/src/client/index.html index f8d0adc76..4c7c27492 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -158,6 +158,15 @@ +
+ + Login with Discord + +
+
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 0306f0aa8..584acb147 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -24,6 +24,7 @@ import { UserSettings } from "../game/UserSettings"; export enum GameEnv { Dev, + Preprod, Prod, } export function getConfig( @@ -64,6 +65,8 @@ export interface ServerConfig { turnIntervalMs(): number; gameCreationRate(): number; lobbyLifetime(): number; + discordRedirectURI(): string; + env(): GameEnv; } export interface Config { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 0a90ac4c8..06ed39aa1 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -17,10 +17,12 @@ import { PlayerView } from "../game/GameView"; import { UserSettings } from "../game/UserSettings"; import { GameConfig } from "../Schemas"; import { assertNever, within } from "../Util"; -import { Config, ServerConfig, Theme } from "./Config"; +import { Config, GameEnv, ServerConfig, Theme } from "./Config"; import { pastelTheme } from "./PastelTheme"; export abstract class DefaultServerConfig implements ServerConfig { + abstract env(): GameEnv; + abstract discordRedirectURI(): string; turnIntervalMs(): number { return 100; } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 03edb8b8b..ac0dd5c16 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -1,16 +1,24 @@ import { GameType, Player, PlayerInfo, UnitInfo, UnitType } from "../game/Game"; import { UserSettings } from "../game/UserSettings"; import { GameConfig } from "../Schemas"; -import { ServerConfig } from "./Config"; +import { GameEnv, ServerConfig } from "./Config"; import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig"; export class DevServerConfig extends DefaultServerConfig { + env(): GameEnv { + return GameEnv.Dev; + } gameCreationRate(): number { return 10 * 1000; } + lobbyLifetime(): number { return 10 * 1000; } + + discordRedirectURI(): string { + return "http://localhost:3000/auth/callback"; + } } export class DevConfig extends DefaultConfig { diff --git a/src/core/configuration/PreprodConfig.ts b/src/core/configuration/PreprodConfig.ts index 387698762..5204ebde8 100644 --- a/src/core/configuration/PreprodConfig.ts +++ b/src/core/configuration/PreprodConfig.ts @@ -1,3 +1,11 @@ +import { GameEnv } from "./Config"; import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig"; -export const preprodConfig = new (class extends DefaultServerConfig {})(); +export const preprodConfig = new (class extends DefaultServerConfig { + env(): GameEnv { + return GameEnv.Preprod; + } + discordRedirectURI(): string { + return "https://openfront.dev/auth/callback"; + } +})(); diff --git a/src/core/configuration/ProdConfig.ts b/src/core/configuration/ProdConfig.ts index 8318a0816..ce3911c48 100644 --- a/src/core/configuration/ProdConfig.ts +++ b/src/core/configuration/ProdConfig.ts @@ -1,3 +1,11 @@ +import { GameEnv } from "./Config"; import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig"; -export const prodConfig = new (class extends DefaultServerConfig {})(); +export const prodConfig = new (class extends DefaultServerConfig { + env(): GameEnv { + return GameEnv.Prod; + } + discordRedirectURI(): string { + return "https://openfront.io/auth/callback"; + } +})(); diff --git a/src/server/Server.ts b/src/server/Server.ts index f1df564bf..51c983253 100644 --- a/src/server/Server.ts +++ b/src/server/Server.ts @@ -11,7 +11,11 @@ import { GameRecordSchema, LogSeverity, } from "../core/Schemas"; -import { getConfig, getServerConfig } from "../core/configuration/Config"; +import { + GameEnv, + getConfig, + getServerConfig, +} from "../core/configuration/Config"; import { slog } from "./StructuredLog"; import { Client } from "./Client"; import { GamePhase, GameServer } from "./GameServer"; @@ -22,7 +26,10 @@ import { validateUsername, } from "../core/validations/username"; import { Request, Response } from "express"; - +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; +import dotenv from "dotenv"; +import crypto from "crypto"; +dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -30,21 +37,90 @@ const app = express(); const server = http.createServer(app); const wss = new WebSocketServer({ server }); +const serverConfig = getServerConfig(); + +// Initialize Secret Manager +const secretManager = new SecretManagerServiceClient(); + +// Discord OAuth Configuration (will be populated from secrets) +let DISCORD_CLIENT_ID: string; +let DISCORD_CLIENT_SECRET: string; + // Serve static files from the 'out' directory app.use(express.static(path.join(__dirname, "../../out"))); app.use(express.json()); -const gm = new GameManager(getServerConfig()); - -const bot = new DiscordBot(); -try { - await bot.start(); -} catch (error) { - console.error("Failed to start bot:", error); -} +const gm = new GameManager(serverConfig); let lobbiesString = ""; +// Discord OAuth callback endpoint +app.get("/auth/callback", async (req: Request, res: Response) => { + const { code } = req.query; + + if (!code) { + return res.status(400).send("No code provided"); + } + + try { + // Exchange code for access token + const tokenResponse = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + body: new URLSearchParams({ + client_id: DISCORD_CLIENT_ID!, + client_secret: DISCORD_CLIENT_SECRET!, + code: code as string, + grant_type: "authorization_code", + redirect_uri: serverConfig.discordRedirectURI(), + }), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (!tokenResponse.ok) { + throw new Error("Failed to get access token"); + } + + const tokenData = await tokenResponse.json(); + + // Get user information + const userResponse = await fetch("https://discord.com/api/users/@me", { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + if (!userResponse.ok) { + throw new Error("Failed to get user information"); + } + + const userData = await userResponse.json(); + const sessionToken = crypto.randomBytes(32).toString("hex"); + + // TODO: store userData and sessionToken in database. + + res.cookie("session", sessionToken, { + httpOnly: true, + secure: true, + sameSite: "strict", + maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds + }); + res.redirect(`/`); + } catch (error) { + console.error("Auth error:", error); + res.status(500).send("Authentication failed"); + } +}); + +app.get("/auth/discord", (req: Request, res: Response) => { + console.log("Redirecting to Discord OAuth..."); + const redirectUri = serverConfig.discordRedirectURI(); + const authorizeUrl = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify`; + console.log("Auth URL:", authorizeUrl); + res.redirect(authorizeUrl); +}); + // New GET endpoint to list lobbies app.get("/lobbies", (req: Request, res: Response) => { res.send(lobbiesString); @@ -61,7 +137,7 @@ app.post("/private_lobby", (req, res) => { app.post("/archive_singleplayer_game", (req, res) => { try { const gameRecord: GameRecord = req.body; - const clientIP = req.ip || req.socket.remoteAddress || "unknown"; // Added this line + const clientIP = req.ip || req.socket.remoteAddress || "unknown"; if (!gameRecord) { console.log("game record not found in request"); @@ -144,7 +220,7 @@ wss.on("connection", (ws, req) => { if (clientMsg.type == "join") { const forwarded = req.headers["x-forwarded-for"]; let ip = Array.isArray(forwarded) - ? forwarded[0] // Get the first IP if it's an array + ? forwarded[0] : forwarded || req.socket.remoteAddress; if (Array.isArray(ip)) { ip = ip[0]; @@ -190,9 +266,18 @@ wss.on("connection", (ws, req) => { }); }); -function runGame() { +function startServer() { setInterval(() => tick(), 1000); setInterval(() => updateLobbies(), 100); + + initializeSecrets(); + + const PORT = process.env.PORT || 3000; + console.log(`Server will try to run on http://localhost:${PORT}`); + + server.listen(PORT, () => { + console.log(`Server is running on http://localhost:${PORT}`); + }); } function tick() { @@ -214,11 +299,36 @@ function updateLobbies() { }); } -const PORT = process.env.PORT || 3000; -console.log(`Server will try to run on http://localhost:${PORT}`); +// Initialize secrets and start server +async function initializeSecrets() { + try { + DISCORD_CLIENT_ID = await getSecret( + "DISCORD_CLIENT_ID", + serverConfig.env(), + ); + DISCORD_CLIENT_SECRET = await getSecret( + "DISCORD_CLIENT_SECRET", + serverConfig.env(), + ); -server.listen(PORT, () => { - console.log(`Server is running on http://localhost:${PORT}`); -}); + if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) { + throw new Error("Failed to load Discord secrets"); + } + } catch (error) { + console.error("Failed to initialize secrets:", error); + process.exit(1); + } +} -runGame(); +async function getSecret(secretName: string, ge: GameEnv) { + if (ge == GameEnv.Dev) { + console.log(`loading secret ${secretName} from environment variable`); + return process.env[secretName]; // This is how you access env vars dynamically + } + console.log(`loading secret ${secretName} from Google secrets manager`); + const name = `projects/openfrontio/secrets/${secretName}/versions/latest`; + const [version] = await secretManager.accessSecretVersion({ name }); + return version.payload?.data?.toString(); +} + +startServer(); diff --git a/webpack.config.js b/webpack.config.js index c6e879fae..30177ad50 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -138,6 +138,8 @@ export default (env, argv) => { "/lobby", "/archive_singleplayer_game", "/validate-username", + "/auth/callback", + "/auth/discord", ], target: "http://localhost:3000", secure: false,