diff --git a/tensors/server/__init__.py b/tensors/server/__init__.py
index 5087e8b..e1a829d 100644
--- a/tensors/server/__init__.py
+++ b/tensors/server/__init__.py
@@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
from scalar_fastapi import get_scalar_api_reference
from tensors.config import get_server_api_key
+from tensors.server.auth_routes import create_auth_router
from tensors.server.civitai_routes import create_civitai_router
from tensors.server.comfyui_routes import create_comfyui_router
from tensors.server.db_routes import create_db_router
@@ -63,6 +64,9 @@ def create_app() -> FastAPI:
title="tensors API",
)
+ # Shared OAuth auth (no API key required)
+ app.include_router(create_auth_router())
+
# ComfyUI proxy (handles its own session auth)
app.include_router(create_comfyui_router())
diff --git a/tensors/server/auth_routes.py b/tensors/server/auth_routes.py
new file mode 100644
index 0000000..e25c948
--- /dev/null
+++ b/tensors/server/auth_routes.py
@@ -0,0 +1,423 @@
+"""Shared OAuth authentication for tensors apps."""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import os
+import secrets
+import time
+import urllib.parse
+
+import httpx
+from fastapi import APIRouter, Cookie, HTTPException, Query, Request, status
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
+
+router = APIRouter(tags=["Auth"])
+
+# Config from environment
+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(",")
+
+# Allowed redirect URLs (for security)
+ALLOWED_REDIRECT_HOSTS = [
+ "tensors.saiden.dev",
+ "localhost",
+ "127.0.0.1",
+]
+
+# OAuth state storage (in-memory, short-lived)
+# Format: state -> (timestamp, return_url)
+_oauth_states: dict[str, tuple[float, str | None]] = {}
+
+_SESSION_TOKEN_PARTS = 3
+
+
+def _create_session_token(username: str) -> str:
+ """Create a signed session token."""
+ expires = int(time.time()) + SESSION_MAX_AGE
+ data = f"{username}:{expires}"
+ signature = hmac.new(SESSION_SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()[:32]
+ return f"{data}:{signature}"
+
+
+def _verify_session_token(token: str | None) -> str | None:
+ """Verify a session token. Returns username if valid, None otherwise."""
+ if not token:
+ return None
+ try:
+ parts = token.split(":")
+ if len(parts) != _SESSION_TOKEN_PARTS:
+ return None
+ username, expires_str, signature = parts
+ expires = int(expires_str)
+ if time.time() > expires:
+ return None
+ data = f"{username}:{expires_str}"
+ expected = hmac.new(SESSION_SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()[:32]
+ if hmac.compare_digest(signature, expected):
+ return username
+ return None
+ except (ValueError, TypeError):
+ return None
+
+
+def _is_auth_configured() -> bool:
+ """Check if GitHub OAuth is configured."""
+ return bool(GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET)
+
+
+def _is_valid_redirect_url(url: str | None) -> bool:
+ """Check if redirect URL is allowed."""
+ if not url:
+ return True
+ try:
+ parsed = urllib.parse.urlparse(url)
+ host = parsed.hostname or ""
+ return host in ALLOWED_REDIRECT_HOSTS or host.endswith(".saiden.dev")
+ except Exception:
+ return False
+
+
+def _cleanup_old_states() -> None:
+ """Remove OAuth states older than 10 minutes."""
+ cutoff = time.time() - 600
+ for state in list(_oauth_states.keys()):
+ if _oauth_states[state][0] < cutoff:
+ del _oauth_states[state]
+
+
+LOGIN_PAGE_HTML = """
+
+
+
+
+
+ Tensors - Login
+
+
+
+
+
+
+"""
+
+
+@router.get("/auth/login")
+async def login_page(
+ return_url: str | None = Query(None, description="URL to redirect after login"),
+ error: str | None = Query(None),
+) -> HTMLResponse:
+ """Show login page."""
+ error_html = ""
+ if error:
+ error_html = f'{error}
'
+
+ # Build auth URL with return_url
+ auth_url = "/auth/github"
+ if return_url and _is_valid_redirect_url(return_url):
+ auth_url += f"?return_url={urllib.parse.quote(return_url)}"
+
+ html = LOGIN_PAGE_HTML.replace("{{ERROR}}", error_html).replace("{{AUTH_URL}}", auth_url)
+ return HTMLResponse(content=html)
+
+
+@router.get("/auth/github")
+async def github_auth(
+ request: Request,
+ return_url: str | None = Query(None, description="URL to redirect after login"),
+) -> Response:
+ """Redirect to GitHub OAuth."""
+ if not _is_auth_configured():
+ return RedirectResponse(
+ url="/auth/login?error=GitHub+OAuth+not+configured",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+
+ if not _is_valid_redirect_url(return_url):
+ return RedirectResponse(
+ url="/auth/login?error=Invalid+redirect+URL",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+
+ # Generate state for CSRF protection, store return_url
+ state = secrets.token_urlsafe(32)
+ _oauth_states[state] = (time.time(), return_url)
+ _cleanup_old_states()
+
+ # Build callback URL
+ callback_url = str(request.url_for("github_callback"))
+
+ # Build GitHub OAuth URL
+ params = {
+ "client_id": GITHUB_CLIENT_ID,
+ "redirect_uri": callback_url,
+ "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("/auth/callback")
+async def github_callback(
+ request: Request,
+ code: str | None = None,
+ state: str | None = None,
+) -> Response:
+ """Handle GitHub OAuth callback."""
+ # Verify state and get return_url
+ if not state or state not in _oauth_states:
+ return RedirectResponse(
+ url="/auth/login?error=Invalid+OAuth+state",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+ _, return_url = _oauth_states.pop(state)
+
+ if not code:
+ error_url = "/auth/login?error=No+authorization+code"
+ if return_url:
+ error_url += f"&return_url={urllib.parse.quote(return_url)}"
+ return RedirectResponse(url=error_url, 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:
+ error_msg = token_data.get("error_description", "OAuth+error")
+ error_url = f"/auth/login?error={error_msg}"
+ if return_url:
+ error_url += f"&return_url={urllib.parse.quote(return_url)}"
+ return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
+
+ access_token = token_data.get("access_token")
+ if not access_token:
+ return RedirectResponse(
+ url="/auth/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="/auth/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="/auth/login?error=User+not+authorized",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+
+ # Create session token
+ token = _create_session_token(username)
+
+ # Redirect to return_url with token, or show success
+ if return_url:
+ # Add token to return URL
+ separator = "&" if "?" in return_url else "?"
+ redirect_url = f"{return_url}{separator}token={token}"
+ return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER)
+
+ # No return_url - set cookie and show success
+ response = RedirectResponse(url="/auth/success", status_code=status.HTTP_303_SEE_OTHER)
+ response.set_cookie(
+ key="tensors_session",
+ value=token,
+ max_age=SESSION_MAX_AGE,
+ httponly=True,
+ samesite="lax",
+ )
+ return response
+
+
+@router.get("/auth/verify")
+async def verify_token(
+ token: str | None = Query(None),
+ tensors_session: str | None = Cookie(default=None),
+) -> JSONResponse:
+ """Verify a session token. Returns user info if valid."""
+ # Check token from query or cookie
+ check_token = token or tensors_session
+
+ username = _verify_session_token(check_token)
+ if username:
+ return JSONResponse({"valid": True, "username": username})
+ return JSONResponse({"valid": False}, status_code=status.HTTP_401_UNAUTHORIZED)
+
+
+@router.get("/auth/success")
+async def auth_success() -> HTMLResponse:
+ """Show success page after login."""
+ html = """
+
+
+
+ Login Successful
+
+
+
+
+
+
+ """
+ return HTMLResponse(content=html)
+
+
+@router.get("/auth/logout")
+async def logout(return_url: str | None = Query(None)) -> Response:
+ """Clear session and redirect."""
+ redirect_to = return_url if _is_valid_redirect_url(return_url) else "/auth/login"
+ response = RedirectResponse(url=redirect_to, status_code=status.HTTP_303_SEE_OTHER)
+ response.delete_cookie("tensors_session")
+ return response
+
+
+def create_auth_router() -> APIRouter:
+ """Return the auth router."""
+ return router