fix: vision(#623): Forgejo OAuth gate for disinto-chat (#708)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/smoke-init Pipeline was successful

Gate /chat/* behind Forgejo OAuth2 authorization-code flow.

- Extract generic _create_forgejo_oauth_app() helper in lib/ci-setup.sh;
  Woodpecker OAuth becomes a thin wrapper, chat gets its own app.
- bin/disinto init now creates TWO OAuth apps (woodpecker-ci + disinto-chat)
  and writes CHAT_OAUTH_CLIENT_ID / CHAT_OAUTH_CLIENT_SECRET to .env.
- docker/chat/server.py: new routes /chat/login (→ Forgejo authorize),
  /chat/oauth/callback (code→token exchange, user allowlist check, session
  cookie). All other /chat/* routes require a valid session or redirect to
  /chat/login. Session store is in-memory with 24h TTL.
- lib/generators.sh: pass FORGE_URL, CHAT_OAUTH_CLIENT_ID,
  CHAT_OAUTH_CLIENT_SECRET, EDGE_TUNNEL_FQDN, DISINTO_CHAT_ALLOWED_USERS
  to the chat container environment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-04-12 01:52:16 +00:00
parent cf4e9983c2
commit 30e19f71e2
6 changed files with 376 additions and 69 deletions

View file

@ -69,6 +69,11 @@ WOODPECKER_DB_USER=woodpecker # [CONFIG] Postgres user
WOODPECKER_DB_HOST=127.0.0.1 # [CONFIG] Postgres host
WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name
# ── Chat OAuth (#708) ────────────────────────────────────────────────────
CHAT_OAUTH_CLIENT_ID= # [SECRET] Chat OAuth2 client ID (auto-generated by init)
CHAT_OAUTH_CLIENT_SECRET= # [SECRET] Chat OAuth2 client secret (auto-generated by init)
DISINTO_CHAT_ALLOWED_USERS= # [CONFIG] CSV of allowed usernames (disinto-admin always allowed)
# ── Vault-only secrets (DO NOT put these in .env) ────────────────────────
# These tokens grant access to external systems (GitHub, ClawHub, deploy targets).
# They live ONLY in .env.vault.enc and are injected into the ephemeral runner

View file

@ -542,6 +542,12 @@ create_woodpecker_oauth() {
_create_woodpecker_oauth_impl "$@"
}
# Create Chat OAuth2 app on Forgejo (implementation in lib/ci-setup.sh)
create_chat_oauth() {
_load_ci_context
_create_chat_oauth_impl "$@"
}
# Generate WOODPECKER_TOKEN via Forgejo OAuth2 flow (implementation in lib/ci-setup.sh)
generate_woodpecker_token() {
_load_ci_context
@ -860,6 +866,15 @@ p.write_text(text)
_WP_REPO_ID=""
create_woodpecker_oauth "$forge_url" "$forge_repo"
# Create OAuth2 app on Forgejo for disinto-chat (#708)
local chat_redirect_uri
if [ -n "${EDGE_TUNNEL_FQDN:-}" ]; then
chat_redirect_uri="https://${EDGE_TUNNEL_FQDN}/chat/oauth/callback"
else
chat_redirect_uri="http://localhost/chat/oauth/callback"
fi
create_chat_oauth "$chat_redirect_uri"
# Generate WOODPECKER_AGENT_SECRET for server↔agent auth
local env_file="${FACTORY_ROOT}/.env"
if ! grep -q '^WOODPECKER_AGENT_SECRET=' "$env_file" 2>/dev/null; then

View file

@ -3,19 +3,32 @@
disinto-chat server minimal HTTP backend for Claude chat UI.
Routes:
GET / serves index.html
GET /static/* serves static assets (htmx.min.js, etc.)
POST /chat spawns `claude --print` with user message, returns response
GET /ws reserved for future streaming upgrade (returns 501)
GET /chat/login 302 to Forgejo OAuth authorize
GET /chat/oauth/callback exchange code for token, validate user, set session
GET /chat/ serves index.html (session required)
GET /chat/static/* serves static assets (session required)
POST /chat spawns `claude --print` with user message (session required)
GET /ws reserved for future streaming upgrade (returns 501)
OAuth flow:
1. User hits any /chat/* route without a valid session cookie 302 /chat/login
2. /chat/login redirects to Forgejo /login/oauth/authorize
3. Forgejo redirects back to /chat/oauth/callback with ?code=...&state=...
4. Server exchanges code for access token, fetches /api/v1/user
5. Asserts user is in allowlist, sets HttpOnly session cookie
6. Redirects to /chat/
The claude binary is expected to be mounted from the host at /usr/local/bin/claude.
"""
import json
import os
import secrets
import subprocess
import sys
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from urllib.parse import urlparse, parse_qs, urlencode
# Configuration
HOST = os.environ.get("CHAT_HOST", "0.0.0.0")
@ -24,6 +37,30 @@ UI_DIR = "/var/chat/ui"
STATIC_DIR = os.path.join(UI_DIR, "static")
CLAUDE_BIN = "/usr/local/bin/claude"
# OAuth configuration
FORGE_URL = os.environ.get("FORGE_URL", "http://localhost:3000")
CHAT_OAUTH_CLIENT_ID = os.environ.get("CHAT_OAUTH_CLIENT_ID", "")
CHAT_OAUTH_CLIENT_SECRET = os.environ.get("CHAT_OAUTH_CLIENT_SECRET", "")
EDGE_TUNNEL_FQDN = os.environ.get("EDGE_TUNNEL_FQDN", "")
# Allowed users — disinto-admin always allowed; CSV allowlist extends it
_allowed_csv = os.environ.get("DISINTO_CHAT_ALLOWED_USERS", "")
ALLOWED_USERS = {"disinto-admin"}
if _allowed_csv:
ALLOWED_USERS.update(u.strip() for u in _allowed_csv.split(",") if u.strip())
# Session cookie name
SESSION_COOKIE = "disinto_chat_session"
# Session TTL: 24 hours
SESSION_TTL = 24 * 60 * 60
# In-memory session store: token → {"user": str, "expires": float}
_sessions = {}
# Pending OAuth state tokens: state → expires (float)
_oauth_states = {}
# MIME types for static files
MIME_TYPES = {
".html": "text/html; charset=utf-8",
@ -37,14 +74,101 @@ MIME_TYPES = {
}
def _build_callback_uri():
"""Build the OAuth callback URI based on tunnel configuration."""
if EDGE_TUNNEL_FQDN:
return f"https://{EDGE_TUNNEL_FQDN}/chat/oauth/callback"
return "http://localhost/chat/oauth/callback"
def _session_cookie_flags():
"""Return cookie flags appropriate for the deployment mode."""
flags = "HttpOnly; SameSite=Lax; Path=/chat"
if EDGE_TUNNEL_FQDN:
flags += "; Secure"
return flags
def _validate_session(cookie_header):
"""Check session cookie and return username if valid, else None."""
if not cookie_header:
return None
for part in cookie_header.split(";"):
part = part.strip()
if part.startswith(SESSION_COOKIE + "="):
token = part[len(SESSION_COOKIE) + 1:]
session = _sessions.get(token)
if session and session["expires"] > time.time():
return session["user"]
# Expired — clean up
_sessions.pop(token, None)
return None
return None
def _gc_sessions():
"""Remove expired sessions (called opportunistically)."""
now = time.time()
expired = [k for k, v in _sessions.items() if v["expires"] <= now]
for k in expired:
del _sessions[k]
expired_states = [k for k, v in _oauth_states.items() if v <= now]
for k in expired_states:
del _oauth_states[k]
def _exchange_code_for_token(code):
"""Exchange an authorization code for an access token via Forgejo."""
import urllib.request
import urllib.error
data = urlencode({
"grant_type": "authorization_code",
"code": code,
"client_id": CHAT_OAUTH_CLIENT_ID,
"client_secret": CHAT_OAUTH_CLIENT_SECRET,
"redirect_uri": _build_callback_uri(),
}).encode()
req = urllib.request.Request(
f"{FORGE_URL}/login/oauth/access_token",
data=data,
headers={"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except (urllib.error.URLError, json.JSONDecodeError, OSError) as e:
print(f"OAuth token exchange failed: {e}", file=sys.stderr)
return None
def _fetch_user(access_token):
"""Fetch the authenticated user from Forgejo API."""
import urllib.request
import urllib.error
req = urllib.request.Request(
f"{FORGE_URL}/api/v1/user",
headers={"Authorization": f"token {access_token}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except (urllib.error.URLError, json.JSONDecodeError, OSError) as e:
print(f"User fetch failed: {e}", file=sys.stderr)
return None
class ChatHandler(BaseHTTPRequestHandler):
"""HTTP request handler for disinto-chat."""
"""HTTP request handler for disinto-chat with Forgejo OAuth."""
def log_message(self, format, *args):
"""Log to stdout instead of stderr."""
"""Log to stderr."""
print(f"[{self.log_date_time_string()}] {format % args}", file=sys.stderr)
def send_error(self, code, message=None):
def send_error_page(self, code, message=None):
"""Custom error response."""
self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8")
@ -52,47 +176,148 @@ class ChatHandler(BaseHTTPRequestHandler):
if message:
self.wfile.write(message.encode("utf-8"))
def _require_session(self):
"""Check session; redirect to /chat/login if missing. Returns username or None."""
user = _validate_session(self.headers.get("Cookie"))
if user:
return user
self.send_response(302)
self.send_header("Location", "/chat/login")
self.end_headers()
return None
def do_GET(self):
"""Handle GET requests."""
parsed = urlparse(self.path)
path = parsed.path
# OAuth routes (no session required)
if path == "/chat/login":
self.handle_login()
return
if path == "/chat/oauth/callback":
self.handle_oauth_callback(parsed.query)
return
# Serve index.html at root
if path == "/" or path == "/chat":
if path in ("/", "/chat", "/chat/"):
if not self._require_session():
return
self.serve_index()
return
# Serve static files
if path.startswith("/static/"):
if path.startswith("/chat/static/") or path.startswith("/static/"):
if not self._require_session():
return
self.serve_static(path)
return
# Reserved WebSocket endpoint (future use)
if path == "/ws" or path.startswith("/ws"):
self.send_error(501, "WebSocket upgrade not yet implemented")
self.send_error_page(501, "WebSocket upgrade not yet implemented")
return
# 404 for unknown paths
self.send_error(404, "Not found")
self.send_error_page(404, "Not found")
def do_POST(self):
"""Handle POST requests."""
parsed = urlparse(self.path)
path = parsed.path
# Chat endpoint
if path == "/chat" or path == "/chat/":
# Chat endpoint (session required)
if path in ("/chat", "/chat/"):
if not self._require_session():
return
self.handle_chat()
return
# 404 for unknown paths
self.send_error(404, "Not found")
self.send_error_page(404, "Not found")
def handle_login(self):
"""Redirect to Forgejo OAuth authorize endpoint."""
_gc_sessions()
if not CHAT_OAUTH_CLIENT_ID:
self.send_error_page(500, "Chat OAuth not configured (CHAT_OAUTH_CLIENT_ID missing)")
return
state = secrets.token_urlsafe(32)
_oauth_states[state] = time.time() + 600 # 10 min validity
params = urlencode({
"client_id": CHAT_OAUTH_CLIENT_ID,
"redirect_uri": _build_callback_uri(),
"response_type": "code",
"state": state,
})
self.send_response(302)
self.send_header("Location", f"{FORGE_URL}/login/oauth/authorize?{params}")
self.end_headers()
def handle_oauth_callback(self, query_string):
"""Exchange authorization code for token, validate user, set session."""
params = parse_qs(query_string)
code = params.get("code", [""])[0]
state = params.get("state", [""])[0]
# Validate state
expected_expiry = _oauth_states.pop(state, None) if state else None
if not expected_expiry or expected_expiry < time.time():
self.send_error_page(400, "Invalid or expired OAuth state")
return
if not code:
self.send_error_page(400, "Missing authorization code")
return
# Exchange code for access token
token_resp = _exchange_code_for_token(code)
if not token_resp or "access_token" not in token_resp:
self.send_error_page(502, "Failed to obtain access token from Forgejo")
return
access_token = token_resp["access_token"]
# Fetch user info
user_info = _fetch_user(access_token)
if not user_info or "login" not in user_info:
self.send_error_page(502, "Failed to fetch user info from Forgejo")
return
username = user_info["login"]
# Check allowlist
if username not in ALLOWED_USERS:
self.send_response(403)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(
f"Not authorised: user '{username}' is not in the allowed users list.\n".encode()
)
return
# Create session
session_token = secrets.token_urlsafe(48)
_sessions[session_token] = {
"user": username,
"expires": time.time() + SESSION_TTL,
}
cookie_flags = _session_cookie_flags()
self.send_response(302)
self.send_header("Set-Cookie", f"{SESSION_COOKIE}={session_token}; {cookie_flags}")
self.send_header("Location", "/chat/")
self.end_headers()
def serve_index(self):
"""Serve the main index.html file."""
index_path = os.path.join(UI_DIR, "index.html")
if not os.path.exists(index_path):
self.send_error(500, "UI not found")
self.send_error_page(500, "UI not found")
return
try:
@ -104,19 +329,23 @@ class ChatHandler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(content.encode("utf-8"))
except IOError as e:
self.send_error(500, f"Error reading index.html: {e}")
self.send_error_page(500, f"Error reading index.html: {e}")
def serve_static(self, path):
"""Serve static files from the static directory."""
# Sanitize path to prevent directory traversal
relative_path = path[len("/static/"):]
# Strip /chat/static/ or /static/ prefix
if path.startswith("/chat/static/"):
relative_path = path[len("/chat/static/"):]
else:
relative_path = path[len("/static/"):]
if ".." in relative_path or relative_path.startswith("/"):
self.send_error(403, "Forbidden")
self.send_error_page(403, "Forbidden")
return
file_path = os.path.join(STATIC_DIR, relative_path)
if not os.path.exists(file_path):
self.send_error(404, "Not found")
self.send_error_page(404, "Not found")
return
# Determine MIME type
@ -132,7 +361,7 @@ class ChatHandler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(content)
except IOError as e:
self.send_error(500, f"Error reading file: {e}")
self.send_error_page(500, f"Error reading file: {e}")
def handle_chat(self):
"""
@ -142,7 +371,7 @@ class ChatHandler(BaseHTTPRequestHandler):
# Read request body
content_length = int(self.headers.get("Content-Length", 0))
if content_length == 0:
self.send_error(400, "No message provided")
self.send_error_page(400, "No message provided")
return
body = self.rfile.read(content_length)
@ -152,16 +381,16 @@ class ChatHandler(BaseHTTPRequestHandler):
params = parse_qs(body_str)
message = params.get("message", [""])[0]
except (UnicodeDecodeError, KeyError):
self.send_error(400, "Invalid message format")
self.send_error_page(400, "Invalid message format")
return
if not message:
self.send_error(400, "Empty message")
self.send_error_page(400, "Empty message")
return
# Validate Claude binary exists
if not os.path.exists(CLAUDE_BIN):
self.send_error(500, "Claude CLI not found")
self.send_error_page(500, "Claude CLI not found")
return
try:
@ -186,7 +415,7 @@ class ChatHandler(BaseHTTPRequestHandler):
# Check for errors
if proc.returncode != 0:
self.send_error(500, f"Claude CLI failed with exit code {proc.returncode}")
self.send_error_page(500, f"Claude CLI failed with exit code {proc.returncode}")
return
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
@ -195,9 +424,9 @@ class ChatHandler(BaseHTTPRequestHandler):
self.wfile.write(response.encode("utf-8"))
except FileNotFoundError:
self.send_error(500, "Claude CLI not found")
self.send_error_page(500, "Claude CLI not found")
except Exception as e:
self.send_error(500, f"Error: {e}")
self.send_error_page(500, f"Error: {e}")
def main():
@ -205,7 +434,12 @@ def main():
server_address = (HOST, PORT)
httpd = HTTPServer(server_address, ChatHandler)
print(f"Starting disinto-chat server on {HOST}:{PORT}", file=sys.stderr)
print(f"UI available at http://localhost:{PORT}/", file=sys.stderr)
print(f"UI available at http://localhost:{PORT}/chat/", file=sys.stderr)
if CHAT_OAUTH_CLIENT_ID:
print(f"OAuth enabled (client_id={CHAT_OAUTH_CLIENT_ID[:8]}...)", file=sys.stderr)
print(f"Allowed users: {', '.join(sorted(ALLOWED_USERS))}", file=sys.stderr)
else:
print("WARNING: CHAT_OAUTH_CLIENT_ID not set — OAuth disabled", file=sys.stderr)
httpd.serve_forever()

View file

@ -29,7 +29,7 @@ sourced as needed.
| `lib/forge-push.sh` | `push_to_forge()` — pushes a local clone to the Forgejo remote and verifies the push. `_assert_forge_push_globals()` validates required env vars before use. Requires `FORGE_URL`, `FORGE_PASS`, `FACTORY_ROOT`, `PRIMARY_BRANCH`. **Auth**: uses `FORGE_PASS` (bot password) for git HTTP push — Forgejo 11.x rejects API tokens for `git push` (#361). | bin/disinto (init) |
| `lib/git-creds.sh` | Shared git credential helper configuration. `configure_git_creds([HOME_DIR] [RUN_AS_CMD])` — writes a static credential helper script and configures git globally to use password-based HTTP auth (Forgejo 11.x rejects API tokens for `git push`, #361). `repair_baked_cred_urls([--as RUN_AS_CMD] DIR ...)` — rewrites any git remote URLs that have credentials baked in to use clean URLs instead; uses `safe.directory` bypass for root-owned repos (#671). Requires `FORGE_PASS`, `FORGE_URL`, `FORGE_TOKEN`. | entrypoints (agents, edge) |
| `lib/ops-setup.sh` | `setup_ops_repo()` — creates ops repo on Forgejo if it doesn't exist, configures bot collaborators, clones/initializes ops repo locally, seeds directory structure (vault, knowledge, evidence, sprints). Evidence subdirectories seeded: engagement/, red-team/, holdout/, evolution/, user-test/. Also seeds sprints/ for architect output. Exports `_ACTUAL_OPS_SLUG`. `migrate_ops_repo(ops_root, [primary_branch])` — idempotent migration helper that seeds missing directories and .gitkeep files on existing ops repos (pre-#407 deployments). | bin/disinto (init) |
| `lib/ci-setup.sh` | `_install_cron_impl()` — installs crontab entries for bare-metal deployments (compose mode uses polling loop instead). `_create_woodpecker_oauth_impl()` — creates OAuth2 app on Forgejo for Woodpecker. `_generate_woodpecker_token_impl()` — auto-generates WOODPECKER_TOKEN via OAuth2 flow. `_activate_woodpecker_repo_impl()` — activates repo in Woodpecker. All gated by `_load_ci_context()` which validates required env vars. | bin/disinto (init) |
| `lib/ci-setup.sh` | `_install_cron_impl()` — installs crontab entries for bare-metal deployments (compose mode uses polling loop instead). `_create_forgejo_oauth_app()` — generic helper to create an OAuth2 app on Forgejo (shared by Woodpecker and chat). `_create_woodpecker_oauth_impl()` — creates Woodpecker OAuth2 app (thin wrapper). `_create_chat_oauth_impl()` — creates disinto-chat OAuth2 app, writes `CHAT_OAUTH_CLIENT_ID`/`CHAT_OAUTH_CLIENT_SECRET` to `.env` (#708). `_generate_woodpecker_token_impl()` — auto-generates WOODPECKER_TOKEN via OAuth2 flow. `_activate_woodpecker_repo_impl()` — activates repo in Woodpecker. All gated by `_load_ci_context()` which validates required env vars. | bin/disinto (init) |
| `lib/generators.sh` | Template generation for `disinto init`: `generate_compose()` — docker-compose.yml (uses `codeberg.org/forgejo/forgejo:11.0` tag; adds `security_opt: [apparmor:unconfined]` to all services for rootless container compatibility; Forgejo includes a healthcheck so dependent services use `condition: service_healthy` — fixes cold-start races, #665; adds `chat` service block with isolated `chat-config` named volume; all `depends_on` now use `condition: service_healthy/started` instead of bare service names), `generate_caddyfile()` — Caddyfile (routes: `/forge/*` → forgejo:3000, `/woodpecker/*` → woodpecker:8000, `/staging/*` → staging:80, `/chat/*` → chat:8080; root `/` redirects to `/forge/`), `generate_staging_index()` — staging index, `generate_deploy_pipelines()` — Woodpecker deployment pipeline configs. Requires `FACTORY_ROOT`, `PROJECT_NAME`, `PRIMARY_BRANCH`. | bin/disinto (init) |
| `lib/hire-agent.sh` | `disinto_hire_an_agent()` — user creation, `.profile` repo setup, formula copying, branch protection, and state marker creation for hiring a new agent. Requires `FORGE_URL`, `FORGE_TOKEN`, `FACTORY_ROOT`, `PROJECT_NAME`. Extracted from `bin/disinto`. | bin/disinto (hire) |
| `lib/release.sh` | `disinto_release()` — vault TOML creation, branch setup on ops repo, PR creation, and auto-merge request for a versioned release. `_assert_release_globals()` validates required env vars. Requires `FORGE_URL`, `FORGE_TOKEN`, `FORGE_OPS_REPO`, `FACTORY_ROOT`, `PRIMARY_BRANCH`. Extracted from `bin/disinto`. | bin/disinto (release) |

View file

@ -4,7 +4,9 @@
#
# Internal functions (called via _load_ci_context + _*_impl):
# _install_cron_impl() - Install crontab entries (bare-metal only; compose uses polling loop)
# _create_forgejo_oauth_app() - Generic: create an OAuth2 app on Forgejo (shared helper)
# _create_woodpecker_oauth_impl() - Create OAuth2 app on Forgejo for Woodpecker
# _create_chat_oauth_impl() - Create OAuth2 app on Forgejo for disinto-chat
# _generate_woodpecker_token_impl() - Auto-generate WOODPECKER_TOKEN via OAuth2 flow
# _activate_woodpecker_repo_impl() - Activate repo in Woodpecker
#
@ -90,6 +92,54 @@ _install_cron_impl() {
fi
}
# Create an OAuth2 application on Forgejo.
# Generic helper used by both Woodpecker and chat OAuth setup.
# Sets _OAUTH_CLIENT_ID and _OAUTH_CLIENT_SECRET on success.
# Usage: _create_forgejo_oauth_app <app_name> <redirect_uri>
_create_forgejo_oauth_app() {
local oauth2_name="$1"
local redirect_uri="$2"
local forge_url="${FORGE_URL}"
_OAUTH_CLIENT_ID=""
_OAUTH_CLIENT_SECRET=""
local existing_app
existing_app=$(curl -sf \
-H "Authorization: token ${FORGE_TOKEN}" \
"${forge_url}/api/v1/user/applications/oauth2" 2>/dev/null \
| jq -r --arg name "$oauth2_name" '.[] | select(.name == $name) | .client_id // empty' 2>/dev/null) || true
if [ -n "$existing_app" ]; then
echo "OAuth2: ${oauth2_name} (already exists, client_id=${existing_app})"
_OAUTH_CLIENT_ID="$existing_app"
return 0
fi
local oauth2_resp
oauth2_resp=$(curl -sf -X POST \
-H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/user/applications/oauth2" \
-d "{\"name\":\"${oauth2_name}\",\"redirect_uris\":[\"${redirect_uri}\"],\"confidential_client\":true}" \
2>/dev/null) || oauth2_resp=""
if [ -z "$oauth2_resp" ]; then
echo "Warning: failed to create OAuth2 app '${oauth2_name}' on Forgejo" >&2
return 1
fi
_OAUTH_CLIENT_ID=$(printf '%s' "$oauth2_resp" | jq -r '.client_id // empty')
_OAUTH_CLIENT_SECRET=$(printf '%s' "$oauth2_resp" | jq -r '.client_secret // empty')
if [ -z "$_OAUTH_CLIENT_ID" ]; then
echo "Warning: OAuth2 app creation returned no client_id" >&2
return 1
fi
echo "OAuth2: ${oauth2_name} created (client_id=${_OAUTH_CLIENT_ID})"
}
# Set up Woodpecker CI to use Forgejo as its forge backend.
# Creates an OAuth2 app on Forgejo for Woodpecker, activates the repo.
# Usage: create_woodpecker_oauth <forge_url> <repo_slug>
@ -100,44 +150,9 @@ _create_woodpecker_oauth_impl() {
echo ""
echo "── Woodpecker OAuth2 setup ────────────────────────────"
# Create OAuth2 application on Forgejo for Woodpecker
local oauth2_name="woodpecker-ci"
local redirect_uri="http://localhost:8000/authorize"
local existing_app client_id client_secret
# Check if OAuth2 app already exists
existing_app=$(curl -sf \
-H "Authorization: token ${FORGE_TOKEN}" \
"${forge_url}/api/v1/user/applications/oauth2" 2>/dev/null \
| jq -r --arg name "$oauth2_name" '.[] | select(.name == $name) | .client_id // empty' 2>/dev/null) || true
if [ -n "$existing_app" ]; then
echo "OAuth2: ${oauth2_name} (already exists, client_id=${existing_app})"
client_id="$existing_app"
else
local oauth2_resp
oauth2_resp=$(curl -sf -X POST \
-H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/user/applications/oauth2" \
-d "{\"name\":\"${oauth2_name}\",\"redirect_uris\":[\"${redirect_uri}\"],\"confidential_client\":true}" \
2>/dev/null) || oauth2_resp=""
if [ -z "$oauth2_resp" ]; then
echo "Warning: failed to create OAuth2 app on Forgejo" >&2
return
fi
client_id=$(printf '%s' "$oauth2_resp" | jq -r '.client_id // empty')
client_secret=$(printf '%s' "$oauth2_resp" | jq -r '.client_secret // empty')
if [ -z "$client_id" ]; then
echo "Warning: OAuth2 app creation returned no client_id" >&2
return
fi
echo "OAuth2: ${oauth2_name} created (client_id=${client_id})"
fi
_create_forgejo_oauth_app "woodpecker-ci" "http://localhost:8000/authorize" || return 0
local client_id="${_OAUTH_CLIENT_ID}"
local client_secret="${_OAUTH_CLIENT_SECRET}"
# Store Woodpecker forge config in .env
# WP_FORGEJO_CLIENT/SECRET match the docker-compose.yml variable references
@ -166,6 +181,39 @@ _create_woodpecker_oauth_impl() {
echo "Config: Woodpecker forge vars written to .env"
}
# Create OAuth2 app on Forgejo for disinto-chat.
# Writes CHAT_OAUTH_CLIENT_ID / CHAT_OAUTH_CLIENT_SECRET to .env.
# Usage: _create_chat_oauth_impl <redirect_uri>
_create_chat_oauth_impl() {
local redirect_uri="$1"
echo ""
echo "── Chat OAuth2 setup ──────────────────────────────────"
_create_forgejo_oauth_app "disinto-chat" "$redirect_uri" || return 0
local client_id="${_OAUTH_CLIENT_ID}"
local client_secret="${_OAUTH_CLIENT_SECRET}"
local env_file="${FACTORY_ROOT}/.env"
local chat_vars=()
if [ -n "${client_id:-}" ]; then
chat_vars+=("CHAT_OAUTH_CLIENT_ID=${client_id}")
fi
if [ -n "${client_secret:-}" ]; then
chat_vars+=("CHAT_OAUTH_CLIENT_SECRET=${client_secret}")
fi
for var_line in "${chat_vars[@]}"; do
local var_name="${var_line%%=*}"
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
sed -i "s|^${var_name}=.*|${var_line}|" "$env_file"
else
printf '%s\n' "$var_line" >> "$env_file"
fi
done
echo "Config: Chat OAuth vars written to .env"
}
# Auto-generate WOODPECKER_TOKEN by driving the Forgejo OAuth2 login flow.
# Requires _FORGE_ADMIN_PASS (set by setup_forge when admin user was just created).
# Called after compose stack is up, before activate_woodpecker_repo.

View file

@ -500,6 +500,11 @@ services:
environment:
CHAT_HOST: "0.0.0.0"
CHAT_PORT: "8080"
FORGE_URL: http://forgejo:3000
CHAT_OAUTH_CLIENT_ID: ${CHAT_OAUTH_CLIENT_ID:-}
CHAT_OAUTH_CLIENT_SECRET: ${CHAT_OAUTH_CLIENT_SECRET:-}
EDGE_TUNNEL_FQDN: ${EDGE_TUNNEL_FQDN:-}
DISINTO_CHAT_ALLOWED_USERS: ${DISINTO_CHAT_ALLOWED_USERS:-}
networks:
- disinto-net