Initial commit: discourse-oauth2-bridge
This commit is contained in:
commit
a8442250e0
5 changed files with 266 additions and 0 deletions
13
.env.example
Normal file
13
.env.example
Normal file
|
|
@ -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
|
||||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
10
Dockerfile
Normal file
10
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
11
package.json
Normal file
11
package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
217
server.js
Normal file
217
server.js
Normal file
|
|
@ -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}`);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue