commit a8442250e090c2f744947860590fb9fd28665ba0 Author: root Date: Tue Jun 23 21:13:40 2026 +0000 Initial commit: discourse-oauth2-bridge diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e2f9078 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Port the bridge listens on inside Docker +PORT=3000 + +# Your Discourse instance +DISCOURSE_URL=https://discourse.example.com +DISCOURSE_SECRET=CHANGE_ME + +# The URL this bridge is accessible at (used in redirects) +BRIDGE_URL=https://auth-bridge.example.com + +# Authentik settings +OAUTH2_CLIENT_ID=CHANGE_ME +OAUTH2_CLIENT_SECRET=CHANGE_ME diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f63fd70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Environment / secrets +.env + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e023afd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json . +RUN npm install --production +COPY server.js . +RUN addgroup -S bridge && adduser -S bridge -G bridge +USER bridge +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1 +CMD ["node", "server.js"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..10841fc --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "discourse-oauth2-bridge", + "version": "1.0.0", + "description": "DiscourseConnect to OAuth2 bridge for Authentik", + "main": "server.js", + "dependencies": { + "express": "^4.18.2", + "crypto": "^1.0.1", + "uuid": "^9.0.0" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..386ea4f --- /dev/null +++ b/server.js @@ -0,0 +1,217 @@ +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}`); +});