const express = require('express'); const crypto = require('crypto'); const { v4: uuidv4 } = require('uuid'); const app = express(); app.use(express.urlencoded({ extended: true })); app.use(express.json()); const config = { discourseUrl: process.env.DISCOURSE_URL, discourseSecret: process.env.DISCOURSE_SECRET, bridgeUrl: process.env.BRIDGE_URL, clientId: process.env.OAUTH2_CLIENT_ID, clientSecret: process.env.OAUTH2_CLIENT_SECRET, port: process.env.PORT || 3000 }; const required = ['discourseUrl', 'discourseSecret', 'bridgeUrl', 'clientId', 'clientSecret']; for (const key of required) { if (!config[key]) { console.error(`Missing required environment variable for: ${key}`); process.exit(1); } } const pendingAuths = new Map(); const authCodes = new Map(); const accessTokens = new Map(); setInterval(() => { const cutoff = Date.now() - 10 * 60 * 1000; for (const [key, val] of pendingAuths) { if (val.createdAt < cutoff) pendingAuths.delete(key); } for (const [key, val] of authCodes) { if (val.createdAt < cutoff) authCodes.delete(key); } for (const [key, val] of accessTokens) { if (val.createdAt < cutoff) accessTokens.delete(key); } }, 10 * 60 * 1000); app.get('/authorize', (req, res) => { const { redirect_uri, state, client_id } = req.query; if (client_id !== config.clientId) { return res.status(401).send('Invalid client_id'); } const nonce = uuidv4().replace(/-/g, ''); pendingAuths.set(nonce, { redirectUri: redirect_uri, state, createdAt: Date.now() }); const returnUrl = `${config.bridgeUrl}/callback`; const payload = Buffer.from(`nonce=${nonce}&return_sso_url=${returnUrl}`).toString('base64'); const sig = crypto.createHmac('sha256', config.discourseSecret).update(payload).digest('hex'); const discourseLoginUrl = `${config.discourseUrl}/session/sso_provider?sso=${encodeURIComponent(payload)}&sig=${sig}`; console.log(`[authorize] Redirecting to Discourse for nonce ${nonce}`); res.redirect(discourseLoginUrl); }); app.get('/callback', (req, res) => { const { sso, sig } = req.query; if (!sso || !sig) { return res.status(400).send('Missing sso or sig parameters'); } const expectedSig = crypto.createHmac('sha256', config.discourseSecret).update(sso).digest('hex'); if (expectedSig !== sig) { console.error('[callback] Signature mismatch!'); return res.status(401).send('Invalid signature'); } const decoded = Buffer.from(sso, 'base64').toString('utf8'); const params = new URLSearchParams(decoded); const nonce = params.get('nonce'); const failed = params.get('failed'); if (failed === 'true') { return res.status(401).send('Discourse authentication failed'); } const pending = pendingAuths.get(nonce); if (!pending) { console.error(`[callback] Unknown nonce: ${nonce}`); return res.status(400).send('Unknown or expired nonce'); } pendingAuths.delete(nonce); const userInfo = { sub: params.get('external_id'), username: params.get('username'), name: params.get('name') || params.get('username'), email: params.get('email'), avatar: params.get('avatar_url'), admin: params.get('admin') === 'true', moderator: params.get('moderator') === 'true', groups: params.get('groups') ? params.get('groups').split(',').filter(g => g) : [], createdAt: Date.now() }; console.log(`[callback] Authenticated user: ${userInfo.username} groups: ${userInfo.groups.join(', ')}`); const code = uuidv4().replace(/-/g, ''); authCodes.set(code, userInfo); const redirectUrl = new URL(pending.redirectUri); redirectUrl.searchParams.set('code', code); if (pending.state) redirectUrl.searchParams.set('state', pending.state); res.redirect(redirectUrl.toString()); }); app.post('/token', (req, res) => { console.log('[token] Headers:', JSON.stringify(req.headers)); console.log('[token] Body:', JSON.stringify(req.body)); // Support both Basic Auth and POST body credentials let clientId = req.body.client_id; let clientSecret = req.body.client_secret; const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Basic ')) { const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); const [id, secret] = decoded.split(':'); clientId = clientId || decodeURIComponent(id); clientSecret = clientSecret || decodeURIComponent(secret); } const { code, grant_type } = req.body; console.log(`[token] client_id: ${clientId}, grant_type: ${grant_type}, code: ${code}`); if (clientId !== config.clientId || clientSecret !== config.clientSecret) { console.error(`[token] Auth failed. Expected client_id: ${config.clientId}, got: ${clientId}`); return res.status(401).json({ error: 'invalid_client' }); } if (grant_type !== 'authorization_code') { return res.status(400).json({ error: 'unsupported_grant_type' }); } const userInfo = authCodes.get(code); if (!userInfo) { console.error(`[token] Unknown or expired code: ${code}`); return res.status(400).json({ error: 'invalid_grant' }); } authCodes.delete(code); const accessToken = uuidv4().replace(/-/g, ''); accessTokens.set(accessToken, userInfo); console.log(`[token] Issued token for user: ${userInfo.username}`); res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: 3600 }); }); app.get('/userinfo', (req, res) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'invalid_token' }); } const token = authHeader.slice(7); const userInfo = accessTokens.get(token); if (!userInfo) { return res.status(401).json({ error: 'invalid_token' }); } console.log(`[userinfo] Returning info for user: ${userInfo.username}`); res.json({ sub: userInfo.sub, preferred_username: userInfo.username, name: userInfo.name, email: userInfo.email, picture: userInfo.avatar, groups: userInfo.groups, discourse_admin: userInfo.admin, discourse_moderator: userInfo.moderator }); }); app.get('/.well-known/openid-configuration', (req, res) => { res.json({ issuer: config.bridgeUrl, authorization_endpoint: `${config.bridgeUrl}/authorize`, token_endpoint: `${config.bridgeUrl}/token`, userinfo_endpoint: `${config.bridgeUrl}/userinfo`, response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], scopes_supported: ['openid', 'profile', 'email'], token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'], claims_supported: ['sub', 'preferred_username', 'name', 'email', 'groups'] }); }); app.get('/health', (req, res) => res.json({ status: 'ok' })); app.listen(config.port, () => { console.log(`Discourse OAuth2 Bridge running on port ${config.port}`); console.log(`Discourse URL: ${config.discourseUrl}`); console.log(`Bridge URL: ${config.bridgeUrl}`); });