first commit

This commit is contained in:
Mikolaj Wojciech Gorski 2025-06-23 22:19:51 +02:00
commit 83a871b42c
7 changed files with 542 additions and 0 deletions

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# .gitignore
# Environment variables
# Contains secrets, should NEVER be committed.
.env
# Python specific
__pycache__/
*.pyc
*.pyo
*.pyd
# Temporary files used by the OIDC Bridge
/tmp/*.json
# IDE / Editor specific
.vscode/
.idea/
*.swp
*.swo

27
Dockerfile Normal file
View file

@ -0,0 +1,27 @@
# Dockerfile
# Instructions to build the Docker image for our OIDC Bridge application.
# Start with a lightweight Python base image.
FROM python:3.9-slim
# Set the working directory inside the container.
WORKDIR /app
# Copy the requirements file to install dependencies.
COPY requirements.txt .
# Install the Python packages.
RUN pip install --no-cache-dir -r requirements.txt
# Create a 'templates' directory for the status page HTML
RUN mkdir templates
# Copy the main application file and the templates.
COPY app.py .
COPY templates/status.html ./templates/
# Expose the port the Flask app will run on.
EXPOSE 5000
# The command to run the application when the container starts.
CMD ["python", "-u", "app.py"]

95
README.md Normal file
View file

@ -0,0 +1,95 @@
# PeerTube Discord OIDC Bridge
A lightweight, containerized OIDC (OpenID Connect) provider that acts as a bridge between a PeerTube instance and Discord's OAuth2 authentication. This allows users to log in or register on a PeerTube instance using their Discord account, with access gated by membership in a specific Discord server.
## Overview
This project solves a specific problem: PeerTube has a robust, official plugin for OIDC authentication, but not for generic OAuth2 providers like Discord. This service fills that gap by presenting a fully compliant OIDC interface to PeerTube while handling the Discord OAuth2 flow on the backend.
The primary use case is for self-hosted PeerTube instances that are not fully public but need a simple way to grant access to a community of users, such as friends or server members, without manual account creation.
## Features
- **Discord Authentication:** Enables "Login with Discord" for any PeerTube instance.
- **Server Gating:** Restricts login/registration to members of a specific Discord server.
- **OIDC Compliant:** Works seamlessly with the official PeerTube `auth-openid-connect` plugin.
- **Automatic Account Creation:** New users who pass the server gate are automatically given a PeerTube account.
- **Existing Account Linking:** Users with an existing PeerTube account can link it by matching their Discord email.
- **Dockerized:** Runs as a single, lightweight Docker container orchestrated with Docker Compose.
- **Diagnostics Page:** Includes a `/status` page to check configuration and connectivity.
## How It Works
1. A user on PeerTube clicks "Login with OpenID Connect".
2. PeerTube redirects the user to this OIDC Bridge service.
3. The OIDC Bridge service redirects the user to Discord for authentication and authorization.
4. The user authorizes the application in Discord and is redirected back to the bridge.
5. The bridge uses the Discord authorization code to get an access token.
6. It then uses the access token to fetch the user's profile and their list of Discord servers (guilds).
7. The bridge creates a signed **ID Token (JWT)**, inserting the user's server IDs into a `groups` claim.
8. PeerTube receives the ID Token, validates it, and checks if the user's `groups` claim contains the required "Allowed Group" (your Discord Server ID).
9. If the check passes, the user is logged in or their account is created.
## Requirements
- [Docker](https://www.docker.com/get-started)
- [Docker Compose](https://docs.docker.com/compose/install/)
- A PeerTube instance with the `auth-openid-connect` plugin installed and enabled.
## Setup Instructions
### 1. Clone or Create Project Files
Place all the project files (`docker-compose.yml`, `Dockerfile`, `app.py`, `requirements.txt`, `.env`, `templates/status.html`) in a single directory on your server.
### 2. Configure Environment Variables
Create a `.env` file by copying the contents from the Canvas and fill in the following values:
- `OIDC_PROVIDER_URL`: The full, publicly accessible URL of this bridge service (e.g., `http://your-domain.com:5000` or `http://192.168.1.10:5000`). **This cannot be `localhost`** if PeerTube is running on a different machine or in a different Docker network.
- `DISCORD_CLIENT_ID`: Your Client ID from the Discord Developer Portal.
- `DISCORD_CLIENT_SECRET`: Your Client Secret from the Discord Developer Portal.
- `PEERTUBE_CALLBACK_URL`: The full callback URL provided by the PeerTube OIDC plugin settings page. It will look like `https://your-peertube.com/plugins/auth-openid-connect/router/code-cb`.
### 3. Configure Discord Application
In the [Discord Developer Portal](https://discord.com/developers/applications), under your application's "OAuth2" settings:
- Add a **Redirect URI** that matches the bridge's callback endpoint: `http://<your-domain-or-ip>:5000/discord/callback`.
### 4. Configure PeerTube Plugin
On your PeerTube instance, navigate to **Administration -> Plugins/Themes -> auth-openid-connect -> Settings** and configure it as follows:
- **Discover URL:** `http://<your-domain-or-ip>:5000/.well-known/openid-configuration`
- **Client ID:** `peertube`
- **Client secret:** `peertube-super-secret`
- **Scope:** `openid email profile groups`
- **Username property:** `preferred_username`
- **Email property:** `email`
- **Display name property:** `name`
- **Group property:** `groups`
- **Allowed group:** Your specific Discord Server ID.
## Running the Service
From the project directory, run the following command:
```bash
docker-compose up --build -d
```
The -d flag runs the container in detached mode (in the background).
Troubleshooting
You can check the health and configuration of the bridge service by navigating to its status page:
http://<your-domain-or-ip>:5000/status
This page will show the status of environment variables, connectivity to the Discord API, and a log of the most recent incoming requests from your PeerTube instance, which is invaluable for debugging the connection.
To view live logs from the container, run:
```bash
docker-compose logs -f
```

274
app.py Normal file
View file

@ -0,0 +1,274 @@
# app.py
# A lightweight OIDC Provider that uses Discord as an upstream identity provider.
# This application is designed to work with the PeerTube OpenID Connect plugin.
import os
import time
import json
import base64
from urllib.parse import urlencode
from collections import deque
from datetime import datetime
import requests
from flask import Flask, request, jsonify, redirect, render_template
# This is the corrected import section. We import from 'jose'.
from jose import jwt, jwk
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)
# --- In-memory store for recent requests ---
# A deque is a double-ended queue with a fixed size to prevent memory leaks.
recent_requests = deque(maxlen=20)
# --- Configuration from Environment Variables ---
# Load all settings from the .env file or system environment.
BASE_URL = os.environ.get('OIDC_PROVIDER_URL')
DISCORD_CLIENT_ID = os.environ.get('DISCORD_CLIENT_ID')
DISCORD_CLIENT_SECRET = os.environ.get('DISCORD_CLIENT_SECRET')
OIDC_CLIENT_ID = os.environ.get('OIDC_CLIENT_ID', 'peertube')
OIDC_CLIENT_SECRET = os.environ.get('OIDC_CLIENT_SECRET', 'peertube-secret')
PEERTUBE_CALLBACK_URL = os.environ.get('PEERTUBE_CALLBACK_URL')
# This URL must match what's in your Discord Developer Portal settings
DISCORD_REDIRECT_URI = f"{BASE_URL}/discord/callback" if BASE_URL else None
# Generate an RSA key pair for signing ID Tokens in memory.
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
# --- Logging Helper ---
def log_request(endpoint, status, error_message=None):
"""Logs an incoming request to our in-memory store."""
recent_requests.appendleft({
"timestamp": datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'),
"endpoint": endpoint,
"ip": request.remote_addr,
"status": status,
"error": error_message
})
# --- OIDC and JWT Helper Functions ---
def get_jwks():
"""Generates the JWKS (JSON Web Key Set) required for token validation."""
pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# Use python-jose to construct the key dictionary
key = jwk.construct(pem, algorithm='RS256')
return {
"keys": [{
"kty": "RSA",
"use": "sig",
"kid": "1", # Key ID
**key.to_dict()
}]
}
def create_id_token(user_info, discord_guilds):
"""Creates a signed ID Token (JWT)."""
now = int(time.time())
claims = {
"iss": BASE_URL,
"sub": user_info['id'],
"aud": OIDC_CLIENT_ID,
"exp": now + 3600,
"iat": now,
"email": user_info['email'],
"email_verified": user_info.get('verified', False),
"preferred_username": f"{user_info['username']}_{user_info['discriminator']}",
"name": user_info.get('global_name') or user_info['username'],
"groups": [guild['id'] for guild in discord_guilds]
}
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
# Use jwt.encode from python-jose
return jwt.encode(claims, private_pem, algorithm="RS256", headers={"kid": "1"})
# --- Discord API Interaction ---
def exchange_discord_code(code):
"""Exchanges a Discord auth code for an access token."""
data = {
'client_id': DISCORD_CLIENT_ID,
'client_secret': DISCORD_CLIENT_SECRET,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': DISCORD_REDIRECT_URI
}
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
r = requests.post('https://discord.com/api/oauth2/token', data=data, headers=headers)
r.raise_for_status()
return r.json()
def get_discord_user_info(access_token):
"""Fetches user profile from Discord."""
headers = {'Authorization': f'Bearer {access_token}'}
r = requests.get('https://discord.com/api/users/@me', headers=headers)
r.raise_for_status()
return r.json()
def get_discord_user_guilds(access_token):
"""Fetches the list of servers the user is in."""
headers = {'Authorization': f'Bearer {access_token}'}
r = requests.get('https://discord.com/api/users/@me/guilds', headers=headers)
r.raise_for_status()
return r.json()
# --- Root and Status Routes ---
@app.route('/')
def root_redirect():
"""Redirects the root URL to the status page for convenience."""
return redirect('/status')
@app.route('/status')
def status_endpoint():
"""Provides a status page for diagnostics."""
# Check environment variables
env_checks = {
'OIDC_PROVIDER_URL': bool(BASE_URL),
'DISCORD_CLIENT_ID': bool(DISCORD_CLIENT_ID),
'DISCORD_CLIENT_SECRET': 'Set' if DISCORD_CLIENT_SECRET else 'Not Set',
'PEERTUBE_CALLBACK_URL': bool(PEERTUBE_CALLBACK_URL)
}
# Check Discord API connectivity
discord_api_status = 'Unknown'
try:
r = requests.get('https://discord.com/api/v10/gateway', timeout=5)
if r.status_code == 200:
discord_api_status = 'OK'
else:
discord_api_status = f"Error - Status Code: {r.status_code}"
except requests.exceptions.RequestException as e:
discord_api_status = f"Failed to connect: {e}"
return render_template('status.html', env_checks=env_checks, discord_api_status=discord_api_status, requests=list(recent_requests))
# --- OIDC Provider Endpoints ---
@app.route('/.well-known/openid-configuration')
def discovery_endpoint():
"""Serves the OIDC discovery document."""
log_request('/.well-known/openid-configuration', 'OK')
return jsonify({
"issuer": BASE_URL,
"authorization_endpoint": f"{BASE_URL}/authorize",
"token_endpoint": f"{BASE_URL}/token",
"userinfo_endpoint": f"{BASE_URL}/userinfo",
"jwks_uri": f"{BASE_URL}/jwks.json",
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile", "email", "groups"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "email", "email_verified", "preferred_username", "name", "groups"]
})
@app.route('/jwks.json')
def jwks_endpoint():
"""Serves the JSON Web Key Set."""
log_request('/jwks.json', 'OK')
return jsonify(get_jwks())
@app.route('/authorize')
def authorize_endpoint():
"""Starts the login flow, redirecting to Discord."""
log_request('/authorize', 'Redirecting to Discord')
discord_auth_params = {
'client_id': DISCORD_CLIENT_ID,
'redirect_uri': DISCORD_REDIRECT_URI,
'response_type': 'code',
'scope': 'identify email guilds',
'state': request.args.get('state')
}
return redirect(f"https://discord.com/api/oauth2/authorize?{urlencode(discord_auth_params)}")
@app.route('/discord/callback')
def discord_callback_endpoint():
"""Handles the callback from Discord and redirects back to PeerTube."""
code = request.args.get('code')
state = request.args.get('state')
if not code:
return "Error: Discord callback missing code.", 400
return redirect(f"{PEERTUBE_CALLBACK_URL}?code={code}&state={state}")
@app.route('/token', methods=['POST'])
def token_endpoint():
"""Exchanges the authorization code for tokens."""
if (request.form.get('client_id') != OIDC_CLIENT_ID or
request.form.get('client_secret') != OIDC_CLIENT_SECRET):
log_request('/token', 'FAIL', 'Invalid client_id or client_secret')
return jsonify({"error": "invalid_client"}), 401
try:
code = request.form.get('code')
discord_tokens = exchange_discord_code(code)
discord_access_token = discord_tokens['access_token']
user_info = get_discord_user_info(discord_access_token)
user_guilds = get_discord_user_guilds(discord_access_token)
id_token = create_id_token(user_info, user_guilds)
access_token = base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8')
with open(f"/tmp/{access_token}.json", "w") as f:
json.dump({**user_info, "groups": [g['id'] for g in user_guilds]}, f)
log_request('/token', 'OK')
return jsonify({
'access_token': access_token,
'token_type': 'Bearer',
'expires_in': 3600,
'id_token': id_token,
})
except Exception as e:
error_msg = str(e)
log_request('/token', 'FAIL', error_msg)
print(f"Error in /token endpoint: {e}")
return jsonify({"error": "server_error"}), 500
@app.route('/userinfo', methods=['GET', 'POST'])
def userinfo_endpoint():
"""Provides user information to PeerTube."""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
log_request('/userinfo', 'FAIL', 'Missing or malformed Authorization header')
return jsonify({"error": "invalid_token"}), 401
access_token = auth_header.split(' ')[1]
try:
with open(f"/tmp/{access_token}.json", "r") as f:
user_data = json.load(f)
log_request('/userinfo', 'OK')
return jsonify({
"sub": user_data['id'],
"email": user_data['email'],
"preferred_username": f"{user_data['username']}_{user_data['discriminator']}",
"name": user_data.get('global_name') or user_data['username'],
"groups": user_data.get('groups', [])
})
except FileNotFoundError:
log_request('/userinfo', 'FAIL', 'Invalid access token')
return jsonify({"error": "invalid_token"}), 401
if __name__ == '__main__':
if not all([BASE_URL, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, PEERTUBE_CALLBACK_URL]):
print("FATAL ERROR: One or more required environment variables are not set.")
print("Please check your .env file.")
else:
app.run(host='0.0.0.0', port=5000)

21
docker-compose.yaml Normal file
View file

@ -0,0 +1,21 @@
# docker-compose.yml
# This file defines and runs our custom OIDC bridge application.
# The PeerTube instance will connect to this service.
version: '3.8'
services:
# The OIDC Bridge Service
# Translates Discord OAuth2 into an OIDC-compliant flow for PeerTube.
oidc-bridge:
# We build the image from the local Dockerfile.
build: .
# The container will restart automatically if it fails.
restart: unless-stopped
ports:
# Expose the service on port 5000.
# PeerTube will connect to this port.
- "5000:5000"
# Mount the .env file to be used by the Python application.
env_file:
- ./.env

6
requirements.txt Normal file
View file

@ -0,0 +1,6 @@
Flask==2.1.2
Werkzeug==2.0.3
requests==2.27.1
cryptography==37.0.2
python-dotenv==0.20.0
python-jose[cryptography]==3.3.0

99
templates/status.html Normal file
View file

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OIDC Bridge Status</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.status-ok { color: #4ade80; } /* green-400 */
.status-fail { color: #f87171; } /* red-400 */
.status-info { color: #60a5fa; } /* blue-400 */
</style>
</head>
<body class="bg-gray-900 text-white flex items-center justify-center min-h-screen p-4">
<div class="bg-gray-800 p-8 rounded-lg shadow-2xl w-full max-w-4xl">
<h1 class="text-2xl font-bold text-cyan-400 mb-6">OIDC Bridge Service Status</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Left Column -->
<div class="space-y-6">
<!-- Environment Variable Checks -->
<div>
<h2 class="text-lg font-semibold text-gray-300 border-b border-gray-600 pb-2 mb-3">Configuration Checks</h2>
<ul class="space-y-2 text-sm">
{% for key, value in env_checks.items() %}
<li class="flex justify-between items-center bg-gray-700 p-3 rounded-md">
<span class="font-mono text-gray-400">{{ key }}</span>
{% if value == True or value == 'Set' %}
<span class="font-bold status-ok">SET</span>
{% else %}
<span class="font-bold status-fail">NOT SET</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<!-- Connectivity Checks -->
<div>
<h2 class="text-lg font-semibold text-gray-300 border-b border-gray-600 pb-2 mb-3">Connectivity Checks</h2>
<ul class="space-y-2 text-sm">
<li class="flex justify-between items-center bg-gray-700 p-3 rounded-md">
<span class="font-mono text-gray-400">Discord API</span>
{% if discord_api_status == 'OK' %}
<span class="font-bold status-ok">OK</span>
{% else %}
<span class="font-bold status-fail">{{ discord_api_status }}</span>
{% endif %}
</li>
</ul>
</div>
</div>
<!-- Right Column for Recent Requests -->
<div>
<h2 class="text-lg font-semibold text-gray-300 border-b border-gray-600 pb-2 mb-3">Recent Incoming Requests</h2>
<div class="bg-gray-700 rounded-md p-1 max-h-80 overflow-y-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs text-gray-400 uppercase bg-gray-700 sticky top-0">
<tr>
<th scope="col" class="px-4 py-2">Timestamp</th>
<th scope="col" class="px-4 py-2">Endpoint</th>
<th scope="col" class="px-4 py-2">Status</th>
</tr>
</thead>
<tbody>
{% if requests %}
{% for req in requests %}
<tr class="border-b border-gray-600">
<td class="px-4 py-2 text-gray-300 font-mono">{{ req.timestamp }}</td>
<td class="px-4 py-2 font-mono">{{ req.endpoint }}</td>
<td class="px-4 py-2 font-bold">
{% if req.status == 'OK' %}
<span class="status-ok">{{ req.status }}</span>
{% elif req.status == 'FAIL' %}
<span class="status-fail" title="{{ req.error }}">{{ req.status }}</span>
{% else %}
<span class="status-info">{{ req.status }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="3" class="text-center py-4 text-gray-500">No requests from PeerTube received yet.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<p class="text-xs text-gray-500 mt-8 text-center">This page can be used to diagnose issues with the OIDC Bridge service.</p>
</div>
</body>
</html>