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

+140 -62
View File
@@ -1,4 +1,4 @@
"""ComfyUI reverse proxy with session authentication.""" """ComfyUI reverse proxy with GitHub OAuth authentication."""
from __future__ import annotations from __future__ import annotations
@@ -6,11 +6,12 @@ import asyncio
import hashlib import hashlib
import hmac import hmac
import os import os
import secrets
import time import time
import httpx import httpx
import websockets 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 from fastapi.responses import HTMLResponse, RedirectResponse
router = APIRouter(tags=["ComfyUI"]) router = APIRouter(tags=["ComfyUI"])
@@ -20,11 +21,17 @@ _SESSION_TOKEN_PARTS = 3
# Config from environment # Config from environment
COMFYUI_URL = os.environ.get("COMFYUI_URL", "http://127.0.0.1:8188") 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_SECRET = os.environ.get("SESSION_SECRET", "tensors-comfyui-secret-change-me")
SESSION_MAX_AGE = 86400 * 7 # 7 days 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: def _create_session_token(username: str) -> str:
"""Create a signed session token.""" """Create a signed session token."""
@@ -53,6 +60,11 @@ def _verify_session_token(token: str | None) -> bool:
return False 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 = """ LOGIN_PAGE_HTML = """
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -101,53 +113,36 @@ LOGIN_PAGE_HTML = """
font-size: 14px; font-size: 14px;
margin-top: 8px; margin-top: 8px;
} }
.form-group { .github-btn {
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 {
width: 100%; width: 100%;
padding: 14px; padding: 14px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: #24292f;
color: #fff; color: #fff;
cursor: pointer; 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); 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); transform: translateY(0);
} }
.github-btn svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.error { .error {
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3); border: 1px solid rgba(239, 68, 68, 0.3);
@@ -172,17 +167,12 @@ LOGIN_PAGE_HTML = """
<p>Stable Diffusion GUI</p> <p>Stable Diffusion GUI</p>
</div> </div>
{{ERROR}} {{ERROR}}
<form method="POST" action="/comfy/login"> <a href="/comfy/auth/github" class="github-btn">
<div class="form-group"> <svg viewBox="0 0 16 16" aria-hidden="true">
<label for="username">Username</label> <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"/>
<input type="text" id="username" name="username" placeholder="Enter username" required autofocus> </svg>
</div> Sign in with GitHub
<div class="form-group"> </a>
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter password" required>
</div>
<button type="submit">Sign In</button>
</form>
<div class="footer"> <div class="footer">
Powered by tensors Powered by tensors
</div> </div>
@@ -202,16 +192,109 @@ async def login_page(error: str | None = None) -> HTMLResponse:
return HTMLResponse(content=html) return HTMLResponse(content=html)
@router.post("/comfy/login") @router.get("/comfy/auth/github")
async def login_submit(username: str = Form(...), password: str = Form(...)) -> Response: async def github_auth(request: Request) -> Response:
"""Handle login form submission.""" """Redirect to GitHub OAuth."""
if not COMFYUI_USER or not COMFYUI_PASS: if not _is_auth_configured():
return RedirectResponse( return RedirectResponse(
url="/comfy/login?error=Authentication+not+configured", url="/comfy/login?error=GitHub+OAuth+not+configured",
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
) )
if username == COMFYUI_USER and password == COMFYUI_PASS: # 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=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) token = _create_session_token(username)
response = RedirectResponse(url="/comfy/", status_code=status.HTTP_303_SEE_OTHER) response = RedirectResponse(url="/comfy/", status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie( response.set_cookie(
@@ -223,11 +306,6 @@ async def login_submit(username: str = Form(...), password: str = Form(...)) ->
) )
return response return response
return RedirectResponse(
url="/comfy/login?error=Invalid+username+or+password",
status_code=status.HTTP_303_SEE_OTHER,
)
@router.get("/comfy/logout") @router.get("/comfy/logout")
async def logout() -> Response: async def logout() -> Response:
@@ -244,7 +322,7 @@ def _check_auth(comfy_session: str | None, path: str = "", method: str = "GET")
because modulepreload/crossorigin requests don't send cookies. because modulepreload/crossorigin requests don't send cookies.
OPTIONS requests (CORS preflight) are also allowed without auth. OPTIONS requests (CORS preflight) are also allowed without auth.
""" """
if not COMFYUI_USER: if not _is_auth_configured():
return return
if method == "OPTIONS": 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: async def proxy_websocket(websocket: WebSocket, comfy_session: str | None = Cookie(default=None)) -> None:
"""Proxy WebSocket connections to ComfyUI.""" """Proxy WebSocket connections to ComfyUI."""
# Check auth via cookie # 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") await websocket.close(code=4001, reason="Unauthorized")
return return