Replace username/password auth with GitHub OAuth for ComfyUI proxy

- Add GitHub OAuth flow with /comfy/auth/github and /comfy/auth/callback
- User allowlist via GITHUB_ALLOWED_USERS environment variable
- CSRF protection with state parameter
- Keep same dark mode login page design (saved screenshot)
- Strip Origin header from proxied requests (fixes ComfyUI 403s)
- Add manual CORS headers to proxy responses

Env vars: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ALLOWED_USERS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adam Ladachowski
2026-02-16 00:36:11 +01:00
parent 4302d76d61
commit 27d119f920
2 changed files with 146 additions and 68 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1004 KiB

+146 -68
View File
@@ -1,4 +1,4 @@
"""ComfyUI reverse proxy with session authentication."""
"""ComfyUI reverse proxy with GitHub OAuth authentication."""
from __future__ import annotations
@@ -6,11 +6,12 @@ import asyncio
import hashlib
import hmac
import os
import secrets
import time
import httpx
import websockets
from fastapi import APIRouter, Cookie, Form, HTTPException, Request, Response, WebSocket, status
from fastapi import APIRouter, Cookie, HTTPException, Request, Response, WebSocket, status
from fastapi.responses import HTMLResponse, RedirectResponse
router = APIRouter(tags=["ComfyUI"])
@@ -20,11 +21,17 @@ _SESSION_TOKEN_PARTS = 3
# Config from environment
COMFYUI_URL = os.environ.get("COMFYUI_URL", "http://127.0.0.1:8188")
COMFYUI_USER = os.environ.get("COMFYUI_USER", "")
COMFYUI_PASS = os.environ.get("COMFYUI_PASS", "")
SESSION_SECRET = os.environ.get("SESSION_SECRET", "tensors-comfyui-secret-change-me")
SESSION_MAX_AGE = 86400 * 7 # 7 days
# GitHub OAuth config
GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "")
GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "")
GITHUB_ALLOWED_USERS = os.environ.get("GITHUB_ALLOWED_USERS", "").split(",")
# OAuth state storage (in-memory, short-lived)
_oauth_states: dict[str, float] = {}
def _create_session_token(username: str) -> str:
"""Create a signed session token."""
@@ -53,6 +60,11 @@ def _verify_session_token(token: str | None) -> bool:
return False
def _is_auth_configured() -> bool:
"""Check if GitHub OAuth is configured."""
return bool(GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET)
LOGIN_PAGE_HTML = """
<!DOCTYPE html>
<html lang="en">
@@ -101,53 +113,36 @@ LOGIN_PAGE_HTML = """
font-size: 14px;
margin-top: 8px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: #b0b0b0;
}
input {
width: 100%;
padding: 14px 16px;
font-size: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
input::placeholder {
color: #666;
}
button {
.github-btn {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: #24292f;
color: #fff;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
text-decoration: none;
}
button:hover {
.github-btn:hover {
background: #32383f;
transform: translateY(-2px);
box-shadow: 0 10px 20px -10px rgba(102, 126, 234, 0.5);
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.5);
}
button:active {
.github-btn:active {
transform: translateY(0);
}
.github-btn svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
@@ -172,17 +167,12 @@ LOGIN_PAGE_HTML = """
<p>Stable Diffusion GUI</p>
</div>
{{ERROR}}
<form method="POST" action="/comfy/login">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Enter username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter password" required>
</div>
<button type="submit">Sign In</button>
</form>
<a href="/comfy/auth/github" class="github-btn">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
Sign in with GitHub
</a>
<div class="footer">
Powered by tensors
</div>
@@ -202,33 +192,121 @@ async def login_page(error: str | None = None) -> HTMLResponse:
return HTMLResponse(content=html)
@router.post("/comfy/login")
async def login_submit(username: str = Form(...), password: str = Form(...)) -> Response:
"""Handle login form submission."""
if not COMFYUI_USER or not COMFYUI_PASS:
@router.get("/comfy/auth/github")
async def github_auth(request: Request) -> Response:
"""Redirect to GitHub OAuth."""
if not _is_auth_configured():
return RedirectResponse(
url="/comfy/login?error=Authentication+not+configured",
url="/comfy/login?error=GitHub+OAuth+not+configured",
status_code=status.HTTP_303_SEE_OTHER,
)
if username == COMFYUI_USER and password == COMFYUI_PASS:
token = _create_session_token(username)
response = RedirectResponse(url="/comfy/", status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
key="comfy_session",
value=token,
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
)
return response
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
_oauth_states[state] = time.time()
# Clean up old states (older than 10 minutes)
cutoff = time.time() - 600
for s in list(_oauth_states.keys()):
if _oauth_states[s] < cutoff:
del _oauth_states[s]
# Build GitHub OAuth URL
params = {
"client_id": GITHUB_CLIENT_ID,
"redirect_uri": str(request.url_for("github_callback")),
"scope": "read:user",
"state": state,
}
query = "&".join(f"{k}={v}" for k, v in params.items())
return RedirectResponse(
url="/comfy/login?error=Invalid+username+or+password",
url=f"https://github.com/login/oauth/authorize?{query}",
status_code=status.HTTP_303_SEE_OTHER,
)
@router.get("/comfy/auth/callback")
async def github_callback(request: Request, code: str | None = None, state: str | None = None) -> Response:
"""Handle GitHub OAuth callback."""
# Verify state
if not state or state not in _oauth_states:
return RedirectResponse(
url="/comfy/login?error=Invalid+OAuth+state",
status_code=status.HTTP_303_SEE_OTHER,
)
del _oauth_states[state]
if not code:
return RedirectResponse(
url="/comfy/login?error=No+authorization+code",
status_code=status.HTTP_303_SEE_OTHER,
)
# Exchange code for access token
async with httpx.AsyncClient() as client:
token_response = await client.post(
"https://github.com/login/oauth/access_token",
data={
"client_id": GITHUB_CLIENT_ID,
"client_secret": GITHUB_CLIENT_SECRET,
"code": code,
},
headers={"Accept": "application/json"},
)
token_data = token_response.json()
if "error" in token_data:
return RedirectResponse(
url=f"/comfy/login?error={token_data.get('error_description', 'OAuth+error')}",
status_code=status.HTTP_303_SEE_OTHER,
)
access_token = token_data.get("access_token")
if not access_token:
return RedirectResponse(
url="/comfy/login?error=No+access+token",
status_code=status.HTTP_303_SEE_OTHER,
)
# Get user info
async with httpx.AsyncClient() as client:
user_response = await client.get(
"https://api.github.com/user",
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
},
)
user_data = user_response.json()
username = user_data.get("login", "")
if not username:
return RedirectResponse(
url="/comfy/login?error=Could+not+get+GitHub+username",
status_code=status.HTTP_303_SEE_OTHER,
)
# Check if user is allowed
allowed = [u.strip().lower() for u in GITHUB_ALLOWED_USERS if u.strip()]
if allowed and username.lower() not in allowed:
return RedirectResponse(
url="/comfy/login?error=User+not+authorized",
status_code=status.HTTP_303_SEE_OTHER,
)
# Create session
token = _create_session_token(username)
response = RedirectResponse(url="/comfy/", status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
key="comfy_session",
value=token,
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
)
return response
@router.get("/comfy/logout")
async def logout() -> Response:
"""Clear session and redirect to login."""
@@ -244,7 +322,7 @@ def _check_auth(comfy_session: str | None, path: str = "", method: str = "GET")
because modulepreload/crossorigin requests don't send cookies.
OPTIONS requests (CORS preflight) are also allowed without auth.
"""
if not COMFYUI_USER:
if not _is_auth_configured():
return
if method == "OPTIONS":
@@ -313,7 +391,7 @@ async def proxy_comfyui(request: Request, path: str, comfy_session: str | None =
async def proxy_websocket(websocket: WebSocket, comfy_session: str | None = Cookie(default=None)) -> None:
"""Proxy WebSocket connections to ComfyUI."""
# Check auth via cookie
if COMFYUI_USER and not _verify_session_token(comfy_session):
if _is_auth_configured() and not _verify_session_token(comfy_session):
await websocket.close(code=4001, reason="Unauthorized")
return