commit 83a871b42cdbc876aec2767e73460c621833d310 Author: Mikolaj Wojciech Gorski Date: Mon Jun 23 22:19:51 2025 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e63f803 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..abd5a8a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1df6b44 --- /dev/null +++ b/README.md @@ -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://: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://: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://: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 +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..e540027 --- /dev/null +++ b/app.py @@ -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) diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..a279899 --- /dev/null +++ b/docker-compose.yaml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e1ad4eb --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/templates/status.html b/templates/status.html new file mode 100644 index 0000000..d4d677f --- /dev/null +++ b/templates/status.html @@ -0,0 +1,99 @@ + + + + + + OIDC Bridge Status + + + + + +
+

OIDC Bridge Service Status

+ +
+ +
+ +
+

Configuration Checks

+
    + {% for key, value in env_checks.items() %} +
  • + {{ key }} + {% if value == True or value == 'Set' %} + SET + {% else %} + NOT SET + {% endif %} +
  • + {% endfor %} +
+
+ + +
+

Connectivity Checks

+
    +
  • + Discord API + {% if discord_api_status == 'OK' %} + OK + {% else %} + {{ discord_api_status }} + {% endif %} +
  • +
+
+
+ + +
+

Recent Incoming Requests

+
+ + + + + + + + + + {% if requests %} + {% for req in requests %} + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
TimestampEndpointStatus
{{ req.timestamp }}{{ req.endpoint }} + {% if req.status == 'OK' %} + {{ req.status }} + {% elif req.status == 'FAIL' %} + {{ req.status }} + {% else %} + {{ req.status }} + {% endif %} +
No requests from PeerTube received yet.
+
+
+
+ +

This page can be used to diagnose issues with the OIDC Bridge service.

+
+ +