Initial commit: Centralized auth service with Discord OAuth
This commit is contained in:
commit
aa5168b201
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
37
db.js
Normal file
37
db.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dbPath = process.env.SANDBOX_DB_PATH || path.resolve(__dirname, '../sandbox/server/sandbox.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
password_hash TEXT,
|
||||||
|
discord_id TEXT UNIQUE,
|
||||||
|
global_name TEXT,
|
||||||
|
avatar TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const migrations = [
|
||||||
|
"ALTER TABLE users ADD COLUMN email TEXT",
|
||||||
|
"ALTER TABLE users ADD COLUMN discord_id TEXT UNIQUE",
|
||||||
|
"ALTER TABLE users ADD COLUMN global_name TEXT",
|
||||||
|
"ALTER TABLE users ADD COLUMN avatar TEXT",
|
||||||
|
"ALTER TABLE users ADD COLUMN updated_at TEXT DEFAULT (datetime('now'))",
|
||||||
|
];
|
||||||
|
for (const sql of migrations) {
|
||||||
|
try { db.exec(sql); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default db;
|
||||||
25
index.js
Normal file
25
index.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import discordRoutes from './routes/discord.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../sandbox/.env') });
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.AUTH_PORT || 3004;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.use('/auth', discordRoutes);
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', service: 'thw-auth', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`THW Auth service running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
18
middleware/auth.js
Normal file
18
middleware/auth.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'sandbox-secret-key-change-in-production';
|
||||||
|
|
||||||
|
export function generateToken(userId) {
|
||||||
|
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticateToken(req, res, next) {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader && authHeader.split(' ')[1];
|
||||||
|
if (!token) return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
||||||
|
if (err) return res.status(403).json({ error: 'Invalid or expired token' });
|
||||||
|
req.userId = decoded.userId;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
1417
package-lock.json
generated
Normal file
1417
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "thw-auth",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Centralized auth service for The Howling Whispers",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"dev": "node --watch index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.11.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
100
routes/discord.js
Normal file
100
routes/discord.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import db from '../db.js';
|
||||||
|
import { generateToken } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const DISCORD_API = 'https://discord.com/api';
|
||||||
|
|
||||||
|
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID;
|
||||||
|
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET;
|
||||||
|
const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI;
|
||||||
|
|
||||||
|
const WHITELIST = new Set([
|
||||||
|
'1207017997173137481'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function getFrontendUrl() {
|
||||||
|
return process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/discord', (req, res) => {
|
||||||
|
const next = req.query.next || '/';
|
||||||
|
const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/';
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: DISCORD_CLIENT_ID,
|
||||||
|
redirect_uri: DISCORD_REDIRECT_URI,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'identify',
|
||||||
|
state: safeNext,
|
||||||
|
});
|
||||||
|
res.redirect(`${DISCORD_API}/oauth2/authorize?${params.toString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/discord/callback', async (req, res) => {
|
||||||
|
const { code, state, error } = req.query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return res.redirect(`${getFrontendUrl()}/login?error=${encodeURIComponent(error)}`);
|
||||||
|
}
|
||||||
|
if (!code || !state) {
|
||||||
|
return res.redirect(`${getFrontendUrl()}/login?error=missing_code_or_state`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: DISCORD_CLIENT_ID,
|
||||||
|
client_secret: DISCORD_CLIENT_SECRET,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: DISCORD_REDIRECT_URI,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
const text = await tokenRes.text();
|
||||||
|
throw new Error(`Discord token exchange failed: ${tokenRes.status} ${text}`);
|
||||||
|
}
|
||||||
|
const token = await tokenRes.json();
|
||||||
|
|
||||||
|
const userRes = await fetch(`${DISCORD_API}/users/@me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token.access_token}` },
|
||||||
|
});
|
||||||
|
if (!userRes.ok) {
|
||||||
|
const text = await userRes.text();
|
||||||
|
throw new Error(`Discord user fetch failed: ${userRes.status} ${text}`);
|
||||||
|
}
|
||||||
|
const discordUser = await userRes.json();
|
||||||
|
|
||||||
|
if (!WHITELIST.has(discordUser.id)) {
|
||||||
|
return res.redirect(`${getFrontendUrl()}/login?error=not_allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT id FROM users WHERE discord_id = ?').get(discordUser.id);
|
||||||
|
let userId;
|
||||||
|
if (existing) {
|
||||||
|
userId = existing.id;
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE users SET username = ?, global_name = ?, avatar = ?, updated_at = datetime('now') WHERE id = ?"
|
||||||
|
).run(discordUser.username, discordUser.global_name, discordUser.avatar, userId);
|
||||||
|
} else {
|
||||||
|
userId = uuidv4();
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO users (id, username, global_name, avatar, discord_id) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(userId, discordUser.username, discordUser.global_name, discordUser.avatar, discordUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwt = generateToken(userId);
|
||||||
|
|
||||||
|
const redirectTo = state.startsWith('/') ? state : '/';
|
||||||
|
res.redirect(`${getFrontendUrl()}${redirectTo}?token=${jwt}`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'unknown error';
|
||||||
|
res.redirect(`${getFrontendUrl()}/login?error=${encodeURIComponent(msg.slice(0, 200))}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Loading…
x
Reference in New Issue
Block a user