From 4c946c9536b0b6574cb11fc1391e0a0e6cf547b2 Mon Sep 17 00:00:00 2001 From: Adam Ladachowski Date: Mon, 16 Feb 2026 00:59:55 +0100 Subject: [PATCH] Add shared OAuth authentication routes Support cross-domain auth for tensors-web with return_url parameter. New endpoints: /auth/login, /auth/github, /auth/callback, /auth/verify Co-Authored-By: Claude Opus 4.5 --- tensors/server/__init__.py | 4 + tensors/server/auth_routes.py | 423 ++++++++++++++++++++++++++++++++++ 2 files changed, 427 insertions(+) create mode 100644 tensors/server/auth_routes.py 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 + + + +
+

Login Successful!

+

You are now authenticated.

+

Go to API Docs

+
+ + + """ + 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