diff --git a/.gitignore b/.gitignore index c38b3b1..339ff2b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,7 @@ venv/ # Generated runnable instance (entirely gitignored - regenerate with build.py) gen/ -# Database dumps (sensitive data) -cfg/*/dumps/*.sql +# Room configurations (separate repo - contains credentials and room-specific data) +# Keep cfg/standalone/ as sample, ignore actual rooms +cfg/amar/ +cfg/dlt/ diff --git a/.woodpecker.yml b/.woodpecker.yml deleted file mode 100644 index 08b634c..0000000 --- a/.woodpecker.yml +++ /dev/null @@ -1,14 +0,0 @@ -# Soleprint Pipeline - -when: - - event: push - - event: manual - -steps: - - name: notify - image: alpine - commands: - - echo "=== Soleprint ===" - - "echo Branch: $CI_COMMIT_BRANCH" - - "echo Commit: $CI_COMMIT_SHA" - - "echo Build locally: ./ctrl/deploy-domains.sh standalone --build" diff --git a/build.py b/build.py index 4257284..323413c 100644 --- a/build.py +++ b/build.py @@ -294,6 +294,10 @@ def build_soleprint(output_dir: Path, room: str): if source.exists(): copy_path(source, output_dir / system) + # Common modules (auth, etc) + if (soleprint / "common").exists(): + copy_path(soleprint / "common", output_dir / "common") + # Room config (includes merging room-specific artery/atlas/station) copy_cfg(output_dir, room) diff --git a/cfg/standalone/data/veins.json b/cfg/standalone/data/veins.json index 8b1ca3f..43a4105 100644 --- a/cfg/standalone/data/veins.json +++ b/cfg/standalone/data/veins.json @@ -18,7 +18,7 @@ "name": "google", "slug": "google", "title": "Google", - "status": "planned", + "status": "building", "system": "artery" }, { diff --git a/soleprint/artery/index.html b/soleprint/artery/index.html index 479e01d..0b1cf81 100644 --- a/soleprint/artery/index.html +++ b/soleprint/artery/index.html @@ -762,12 +762,116 @@ - -
-

Google

-

Google connector. Planned.

+ +
+

Google Sheets

+ + +
+
+

+ Connect your Google account to access Sheets. +

+
+ +
+
+ +
+ + + + + +
+
+
+ +

Endpoints

+ +

+ Add ?text=true for LLM-friendly output. +

+ +

Maps

Maps connector. Planned.

@@ -1487,6 +1591,223 @@ showError(output, e.message); } }); + + // ===================================================================== + // Google Tab + // ===================================================================== + + const googleNotConnected = document.getElementById( + "google-not-connected", + ); + const googleConnected = document.getElementById("google-connected"); + const googleSheetsForm = + document.getElementById("google-sheets-form"); + const googleOutput = document.getElementById("google-output"); + const googleOutputContainer = document.getElementById( + "google-output-container", + ); + + async function checkGoogleAuth() { + try { + const res = await fetch("/artery/google_api/oauth/status"); + const data = await res.json(); + + if (data.authenticated) { + googleNotConnected.style.display = "none"; + googleConnected.style.display = "block"; + googleSheetsForm.style.display = "block"; + } else { + googleNotConnected.style.display = "block"; + googleConnected.style.display = "none"; + googleSheetsForm.style.display = "none"; + } + } catch (e) { + console.error("Failed to check Google auth status:", e); + } + } + + // Check auth on page load if Google tab elements exist + if (googleNotConnected) { + checkGoogleAuth(); + } + + // Also check when switching to Google API tab + document + .querySelectorAll('.vein[data-tab="google_api"]') + .forEach((vein) => { + vein.addEventListener("click", () => { + checkGoogleAuth(); + }); + }); + + // Connect button + document + .getElementById("btn-google-connect") + ?.addEventListener("click", () => { + // Redirect to OAuth start, will come back to /artery after auth + window.location.href = + "/artery/google_api/oauth/start?redirect=/artery"; + }); + + // Disconnect button + document + .getElementById("btn-google-disconnect") + ?.addEventListener("click", async () => { + await fetch("/artery/google_api/oauth/logout"); + checkGoogleAuth(); + googleOutputContainer.classList.remove("visible"); + }); + + // Google output helpers + function showGoogleOutput(text, isError = false) { + googleOutput.textContent = text; + googleOutput.classList.toggle("error", isError); + googleOutputContainer.classList.add("visible"); + } + + // List Sheets + document + .getElementById("btn-list-sheets") + ?.addEventListener("click", async () => { + const spreadsheetId = document + .getElementById("spreadsheet-id") + .value.trim(); + if (!spreadsheetId) { + showGoogleOutput( + "Error: Please enter a Spreadsheet ID", + true, + ); + return; + } + + const textMode = + document.getElementById("google-text-mode").checked; + showGoogleOutput("Loading..."); + + try { + const params = new URLSearchParams(); + if (textMode) params.set("text", "true"); + + const res = await fetch( + `/artery/google_api/spreadsheets/${spreadsheetId}/sheets?${params}`, + ); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || res.statusText); + } + + if (textMode) { + showGoogleOutput(await res.text()); + } else { + showGoogleOutput( + JSON.stringify(await res.json(), null, 2), + ); + } + } catch (e) { + showGoogleOutput("Error: " + e.message, true); + } + }); + + // Get Metadata + document + .getElementById("btn-get-metadata") + ?.addEventListener("click", async () => { + const spreadsheetId = document + .getElementById("spreadsheet-id") + .value.trim(); + if (!spreadsheetId) { + showGoogleOutput( + "Error: Please enter a Spreadsheet ID", + true, + ); + return; + } + + const textMode = + document.getElementById("google-text-mode").checked; + showGoogleOutput("Loading..."); + + try { + const params = new URLSearchParams(); + if (textMode) params.set("text", "true"); + + const res = await fetch( + `/artery/google_api/spreadsheets/${spreadsheetId}?${params}`, + ); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || res.statusText); + } + + if (textMode) { + showGoogleOutput(await res.text()); + } else { + showGoogleOutput( + JSON.stringify(await res.json(), null, 2), + ); + } + } catch (e) { + showGoogleOutput("Error: " + e.message, true); + } + }); + + // Get Values + document + .getElementById("btn-get-values") + ?.addEventListener("click", async () => { + const spreadsheetId = document + .getElementById("spreadsheet-id") + .value.trim(); + const range = document + .getElementById("sheet-range") + .value.trim(); + + if (!spreadsheetId) { + showGoogleOutput( + "Error: Please enter a Spreadsheet ID", + true, + ); + return; + } + if (!range) { + showGoogleOutput( + "Error: Please enter a Range (e.g., Sheet1!A1:D10)", + true, + ); + return; + } + + const textMode = + document.getElementById("google-text-mode").checked; + showGoogleOutput("Loading..."); + + try { + const params = new URLSearchParams(); + params.set("range", range); + if (textMode) params.set("text", "true"); + + const res = await fetch( + `/artery/google_api/spreadsheets/${spreadsheetId}/values?${params}`, + ); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || res.statusText); + } + + if (textMode) { + showGoogleOutput(await res.text()); + } else { + showGoogleOutput( + JSON.stringify(await res.json(), null, 2), + ); + } + } catch (e) { + showGoogleOutput("Error: " + e.message, true); + } + }); diff --git a/soleprint/artery/veins/google/api/routes.py b/soleprint/artery/veins/google/api/routes.py index e168276..4439669 100644 --- a/soleprint/artery/veins/google/api/routes.py +++ b/soleprint/artery/veins/google/api/routes.py @@ -2,22 +2,19 @@ API routes for Google vein. """ -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import PlainTextResponse, RedirectResponse from typing import Optional -from core.oauth import GoogleOAuth -from core.sheets import GoogleSheetsClient, GoogleSheetsError -from models.spreadsheet import SpreadsheetMetadata, SheetValues -from models.formatter import format_spreadsheet_metadata, format_sheet_values +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import PlainTextResponse, RedirectResponse -# Import from parent vein module -import sys -from pathlib import Path -vein_path = Path(__file__).parent.parent.parent -sys.path.insert(0, str(vein_path)) +# Import shared OAuth utilities from veins parent from oauth import TokenStorage +from ..core.oauth import GoogleOAuth +from ..core.sheets import GoogleSheetsClient, GoogleSheetsError +from ..models.formatter import format_sheet_values, format_spreadsheet_metadata +from ..models.spreadsheet import SheetValues, SpreadsheetMetadata + router = APIRouter() # OAuth client and token storage @@ -71,18 +68,30 @@ def _maybe_text(data, text: bool, formatter): @router.get("/health") async def health(): - """Check if user is authenticated.""" + """Check vein health and configuration status.""" + from ..core.config import settings + + configured = bool(settings.google_client_id and settings.google_client_secret) + if not configured: + return { + "status": "not_configured", + "configured": False, + "message": "Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env", + } + try: tokens = token_storage.load_tokens(DEFAULT_USER_ID) if not tokens: return { "status": "not_authenticated", - "message": "Visit /google/oauth/start to login", + "configured": True, + "message": "Visit /artery/google/oauth/start to login", } expired = token_storage.is_expired(tokens) return { "status": "ok" if not expired else "token_expired", + "configured": True, "has_refresh_token": "refresh_token" in tokens, "user": DEFAULT_USER_ID, } @@ -91,9 +100,25 @@ async def health(): @router.get("/oauth/start") -async def start_oauth(state: Optional[str] = None): - """Start OAuth flow - redirect to Google authorization.""" - auth_url = oauth_client.get_authorization_url(state=state) +async def start_oauth( + state: Optional[str] = None, + hd: Optional[str] = None, + redirect: Optional[str] = None, +): + """ + Start OAuth flow - redirect to Google authorization. + + Args: + state: CSRF token (passed through to callback) + hd: Hosted domain hint (e.g., 'company.com') to pre-select account + redirect: URL to redirect after successful auth + """ + # Encode redirect in state if provided + full_state = state or "" + if redirect: + full_state = f"{full_state}|{redirect}" if full_state else redirect + + auth_url = oauth_client.get_authorization_url(state=full_state, hd=hd) return RedirectResponse(auth_url) @@ -122,6 +147,23 @@ async def oauth_callback( raise HTTPException(500, f"Failed to exchange code: {e}") +@router.get("/oauth/userinfo") +async def get_userinfo( + code: str = Query(..., description="Authorization code from callback"), +): + """ + Exchange code and return user info (identity flow). + + Used by common/auth for login - returns user identity without storing tokens. + For API access flows, use /oauth/callback instead. + """ + try: + user_info = oauth_client.exchange_code_for_user(code) + return user_info + except Exception as e: + raise HTTPException(400, f"Failed to get user info: {e}") + + @router.get("/oauth/logout") async def logout(): """Clear stored tokens.""" diff --git a/soleprint/artery/veins/google/core/config.py b/soleprint/artery/veins/google/core/config.py index a3b688e..a47fec8 100644 --- a/soleprint/artery/veins/google/core/config.py +++ b/soleprint/artery/veins/google/core/config.py @@ -3,21 +3,37 @@ Google OAuth2 configuration loaded from .env file. """ from pathlib import Path + from pydantic_settings import BaseSettings ENV_FILE = Path(__file__).parent.parent / ".env" +# OpenID scopes for identity verification +IDENTITY_SCOPES = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +] + +# API scopes for data access (Sheets, Drive) +API_SCOPES = [ + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/drive.readonly", +] + class GoogleConfig(BaseSettings): - google_client_id: str - google_client_secret: str - google_redirect_uri: str # e.g., https://artery.mcrn.ar/google/oauth/callback - google_scopes: str = "https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/drive.readonly" + google_client_id: str = "" + google_client_secret: str = "" + google_redirect_uri: str = "http://localhost:12000/artery/google/oauth/callback" + # Default to identity-only scopes; add API scopes when needed + google_scopes: str = " ".join(IDENTITY_SCOPES) api_port: int = 8003 model_config = { "env_file": ENV_FILE, "env_file_encoding": "utf-8", + "extra": "ignore", } diff --git a/soleprint/artery/veins/google/core/oauth.py b/soleprint/artery/veins/google/core/oauth.py index e9637ac..e64d0cf 100644 --- a/soleprint/artery/veins/google/core/oauth.py +++ b/soleprint/artery/veins/google/core/oauth.py @@ -2,12 +2,17 @@ Google OAuth2 flow implementation. Isolated OAuth2 client that can run without FastAPI. +Supports both identity (OpenID) and API access flows. """ from typing import Optional + +import requests from google.auth.transport.requests import Request +from google.oauth2 import id_token from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import Flow + from .config import settings @@ -52,21 +57,29 @@ class GoogleOAuth: ) return flow - def get_authorization_url(self, state: Optional[str] = None) -> str: + def get_authorization_url( + self, state: Optional[str] = None, hd: Optional[str] = None + ) -> str: """ Generate OAuth2 authorization URL. Args: state: Optional state parameter for CSRF protection + hd: Hosted domain hint (e.g., 'company.com') to pre-select account Returns: URL to redirect user for Google authorization """ flow = self._create_flow() + extra_params = {} + if hd: + extra_params["hd"] = hd + auth_url, _ = flow.authorization_url( access_type="offline", # Request refresh token include_granted_scopes="true", state=state, + **extra_params, ) return auth_url @@ -97,6 +110,42 @@ class GoogleOAuth: "token_type": "Bearer", } + def exchange_code_for_user(self, code: str) -> dict: + """ + Exchange authorization code and return user identity info. + + Used for identity/login flows (OpenID Connect). + + Args: + code: Authorization code from callback + + Returns: + User info dict containing: + - email + - name + - picture + - hd (hosted domain, if Google Workspace account) + """ + flow = self._create_flow() + flow.fetch_token(code=code) + + credentials = flow.credentials + + # Fetch user info from Google's userinfo endpoint + userinfo_response = requests.get( + "https://www.googleapis.com/oauth2/v3/userinfo", + headers={"Authorization": f"Bearer {credentials.token}"}, + ) + userinfo_response.raise_for_status() + userinfo = userinfo_response.json() + + return { + "email": userinfo.get("email"), + "name": userinfo.get("name"), + "picture": userinfo.get("picture"), + "hd": userinfo.get("hd"), # Hosted domain (Google Workspace) + } + def refresh_access_token(self, refresh_token: str) -> dict: """ Refresh an expired access token. @@ -126,7 +175,9 @@ class GoogleOAuth: "token_type": "Bearer", } - def get_credentials(self, access_token: str, refresh_token: Optional[str] = None) -> Credentials: + def get_credentials( + self, access_token: str, refresh_token: Optional[str] = None + ) -> Credentials: """ Create Google Credentials object from tokens. diff --git a/soleprint/artery/veins/jira/core/config.py b/soleprint/artery/veins/jira/core/config.py index eee6aaf..9174a65 100644 --- a/soleprint/artery/veins/jira/core/config.py +++ b/soleprint/artery/veins/jira/core/config.py @@ -3,20 +3,24 @@ Jira credentials loaded from .env file. """ from pathlib import Path + from pydantic_settings import BaseSettings ENV_FILE = Path(__file__).parent.parent / ".env" class JiraConfig(BaseSettings): - jira_url: str + jira_url: str = "" # Required for use, optional for loading jira_email: str | None = None # Optional: can be provided per-request via headers - jira_api_token: str | None = None # Optional: can be provided per-request via headers + jira_api_token: str | None = ( + None # Optional: can be provided per-request via headers + ) api_port: int = 8001 model_config = { "env_file": ENV_FILE, "env_file_encoding": "utf-8", + "extra": "ignore", } diff --git a/soleprint/artery/veins/oauth.py b/soleprint/artery/veins/oauth.py index ac4a83a..50cb1aa 100644 --- a/soleprint/artery/veins/oauth.py +++ b/soleprint/artery/veins/oauth.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Optional -from .base import BaseVein, TClient, TCredentials +from base import BaseVein, TClient, TCredentials class TokenStorage: diff --git a/soleprint/common/__init__.py b/soleprint/common/__init__.py new file mode 100644 index 0000000..ff542b8 --- /dev/null +++ b/soleprint/common/__init__.py @@ -0,0 +1,3 @@ +""" +Common module - shared abstractions reusable across soleprint systems. +""" diff --git a/soleprint/common/auth/__init__.py b/soleprint/common/auth/__init__.py new file mode 100644 index 0000000..412732f --- /dev/null +++ b/soleprint/common/auth/__init__.py @@ -0,0 +1,10 @@ +""" +Generic authentication framework for soleprint. + +Provider-agnostic - delegates to configured provider vein (e.g., google_login). +""" + +from .config import AuthConfig, load_auth_config +from .session import get_current_user, require_auth + +__all__ = ["AuthConfig", "load_auth_config", "get_current_user", "require_auth"] diff --git a/soleprint/common/auth/config.py b/soleprint/common/auth/config.py new file mode 100644 index 0000000..08f97ef --- /dev/null +++ b/soleprint/common/auth/config.py @@ -0,0 +1,43 @@ +""" +Authentication configuration. + +Generic config that works with any provider vein. +""" + +from typing import Optional + +from pydantic import BaseModel + + +class AuthConfig(BaseModel): + """Authentication configuration for a room.""" + + enabled: bool = False + provider: str = "google" # Vein name to use for auth + allowed_domains: list[str] = [] # Empty = allow any domain + session_secret: str = "" # Required if enabled, can be "ENV:VAR_NAME" + session_timeout_hours: int = 24 + login_redirect: str = "/" + public_routes: list[str] = [ + "/health", + "/auth/login", + "/auth/callback", + "/auth/logout", + ] + + +def load_auth_config(config: dict) -> Optional[AuthConfig]: + """ + Load auth config from room config.json. + + Returns None if auth is not enabled. + """ + auth_data = config.get("auth") + if not auth_data: + return None + + auth_config = AuthConfig(**auth_data) + if not auth_config.enabled: + return None + + return auth_config diff --git a/soleprint/common/auth/middleware.py b/soleprint/common/auth/middleware.py new file mode 100644 index 0000000..17a3fa1 --- /dev/null +++ b/soleprint/common/auth/middleware.py @@ -0,0 +1,91 @@ +""" +Authentication middleware for route protection. + +Generic middleware, provider-agnostic. +""" + +from datetime import datetime + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse, RedirectResponse + +from .config import AuthConfig + + +class AuthMiddleware(BaseHTTPMiddleware): + """ + Middleware that protects routes by requiring authentication. + + - Public routes (configurable) are allowed without auth + - Unauthenticated browser requests redirect to /auth/login + - Unauthenticated API requests get 401 JSON response + """ + + def __init__(self, app, auth_config: AuthConfig): + super().__init__(app) + self.config = auth_config + self.public_routes = set(auth_config.public_routes) + # Also allow static files and common paths + self.public_prefixes = ["/static", "/favicon", "/artery"] + + async def dispatch(self, request, call_next): + path = request.url.path + + # Check if route is public + if self._is_public(path): + return await call_next(request) + + # Check session + session = request.session + user_email = session.get("user_email") + expires_at = session.get("expires_at") + + if not user_email: + return self._unauthorized(request, "Not authenticated") + + # Check expiry + if expires_at: + if datetime.fromisoformat(expires_at) < datetime.now(): + session.clear() + return self._unauthorized(request, "Session expired") + + # Check domain restriction + user_domain = session.get("domain") + if self.config.allowed_domains: + if not user_domain or user_domain not in self.config.allowed_domains: + session.clear() + return self._unauthorized( + request, + f"Access restricted to: {', '.join(self.config.allowed_domains)}", + ) + + # Attach user to request state for downstream use + request.state.user = { + "email": user_email, + "name": session.get("user_name"), + "domain": user_domain, + } + + return await call_next(request) + + def _is_public(self, path: str) -> bool: + """Check if path is public (no auth required).""" + if path in self.public_routes: + return True + for prefix in self.public_prefixes: + if path.startswith(prefix): + return True + return False + + def _unauthorized(self, request, message: str): + """Return appropriate unauthorized response.""" + # API requests get JSON 401 + accept = request.headers.get("accept", "") + if "application/json" in accept: + return JSONResponse({"error": message}, status_code=401) + + # Browser requests redirect to login with return URL + next_url = str(request.url.path) + if request.url.query: + next_url += f"?{request.url.query}" + return RedirectResponse(url=f"/auth/login?next={next_url}") diff --git a/soleprint/common/auth/routes.py b/soleprint/common/auth/routes.py new file mode 100644 index 0000000..6ca9a87 --- /dev/null +++ b/soleprint/common/auth/routes.py @@ -0,0 +1,170 @@ +""" +Authentication routes. + +Generic routes that delegate to configured provider vein. + +/auth/login - Start login flow (redirects to provider) +/auth/callback - Handle provider callback, create session +/auth/logout - Clear session +/auth/me - Get current user info +""" + +import os +import secrets +from datetime import datetime, timedelta +from typing import Optional + +import httpx +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse + +from .config import AuthConfig + +router = APIRouter(prefix="/auth", tags=["auth"]) + +# Will be initialized by setup_auth() in run.py +auth_config: Optional[AuthConfig] = None + + +def init_auth(config: AuthConfig): + """ + Initialize auth module with configuration. + + Called by run.py when setting up authentication. + """ + global auth_config + auth_config = config + + +def _get_provider_base_url() -> str: + """Get base URL for the configured provider vein.""" + if not auth_config: + raise HTTPException(500, "Auth not configured") + # Provider is a vein name like "google_login" + return f"/artery/{auth_config.provider}" + + +@router.get("/login") +async def login(request: Request, next: str = "/"): + """ + Start login flow. + + Redirects to the configured provider vein's OAuth start endpoint. + """ + if not auth_config: + raise HTTPException(500, "Auth not configured") + + # Generate CSRF state token + state = secrets.token_urlsafe(32) + request.session["oauth_state"] = state + request.session["oauth_next"] = next + + # Get domain hint from config (first allowed domain) + hd = auth_config.allowed_domains[0] if auth_config.allowed_domains else None + + # Build provider OAuth URL + provider_url = _get_provider_base_url() + params = f"?state={state}" + if hd: + params += f"&hd={hd}" + + # Redirect includes callback to our /auth/callback + return RedirectResponse(url=f"{provider_url}/oauth/start{params}") + + +@router.get("/callback") +async def callback( + request: Request, + code: Optional[str] = None, + state: Optional[str] = None, + error: Optional[str] = None, +): + """ + Handle OAuth callback. + + Receives code from provider, exchanges for user info, creates session. + """ + if not auth_config: + raise HTTPException(500, "Auth not configured") + + if error: + raise HTTPException(400, f"OAuth error: {error}") + + # Verify state + expected_state = request.session.get("oauth_state") + if not state or state != expected_state: + raise HTTPException(400, "Invalid state parameter") + + # Call provider vein to exchange code for user info + provider_url = _get_provider_base_url() + try: + async with httpx.AsyncClient() as client: + # Get base URL from request + base_url = str(request.base_url).rstrip("/") + response = await client.get( + f"{base_url}{provider_url}/oauth/userinfo", + params={"code": code}, + ) + if response.status_code != 200: + raise HTTPException(400, f"Provider error: {response.text}") + user_info = response.json() + except httpx.RequestError as e: + raise HTTPException(500, f"Failed to contact provider: {e}") + + # Verify domain if restricted + user_domain = user_info.get("hd") + if auth_config.allowed_domains: + if not user_domain or user_domain not in auth_config.allowed_domains: + raise HTTPException( + 403, + f"Access restricted to: {', '.join(auth_config.allowed_domains)}. " + f"Your account is from: {user_domain or 'personal Gmail'}", + ) + + # Create session + expires_at = datetime.now() + timedelta(hours=auth_config.session_timeout_hours) + request.session.update( + { + "user_email": user_info["email"], + "user_name": user_info.get("name"), + "user_picture": user_info.get("picture"), + "domain": user_domain, + "authenticated_at": datetime.now().isoformat(), + "expires_at": expires_at.isoformat(), + } + ) + + # Clean up oauth state + request.session.pop("oauth_state", None) + next_url = request.session.pop("oauth_next", "/") + + return RedirectResponse(url=next_url) + + +@router.get("/logout") +async def logout(request: Request): + """Clear session and redirect to login.""" + request.session.clear() + return RedirectResponse(url="/auth/login") + + +@router.get("/me") +async def me(request: Request): + """ + Return current user info. + + API endpoint for checking auth status. + """ + user = getattr(request.state, "user", None) + if not user: + # Try to get from session directly (in case middleware didn't run) + user_email = request.session.get("user_email") + if not user_email: + raise HTTPException(401, "Not authenticated") + user = { + "email": user_email, + "name": request.session.get("user_name"), + "picture": request.session.get("user_picture"), + "domain": request.session.get("domain"), + } + return user diff --git a/soleprint/common/auth/session.py b/soleprint/common/auth/session.py new file mode 100644 index 0000000..59db070 --- /dev/null +++ b/soleprint/common/auth/session.py @@ -0,0 +1,51 @@ +""" +Session helpers for authentication. + +Generic session management, provider-agnostic. +""" + +from datetime import datetime +from typing import Optional + +from fastapi import HTTPException, Request + + +def get_current_user(request: Request) -> Optional[dict]: + """ + Get current authenticated user from session. + + Returns: + User dict with email, name, domain, etc. or None if not authenticated. + """ + session = getattr(request, "session", None) + if not session: + return None + + user_email = session.get("user_email") + if not user_email: + return None + + # Check expiry + expires_at = session.get("expires_at") + if expires_at: + if datetime.fromisoformat(expires_at) < datetime.now(): + return None + + return { + "email": user_email, + "name": session.get("user_name"), + "picture": session.get("user_picture"), + "domain": session.get("domain"), + } + + +def require_auth(request: Request) -> dict: + """ + Get current user or raise 401. + + For use as FastAPI dependency. + """ + user = get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return user diff --git a/soleprint/run.py b/soleprint/run.py index 6427b1d..8ceb9e5 100644 --- a/soleprint/run.py +++ b/soleprint/run.py @@ -11,6 +11,7 @@ Usage: This is for soleprint development only, not for managed rooms (use docker for those). """ +import importlib.util import json import os import sys @@ -34,6 +35,212 @@ DATA_DIR = SPR_ROOT / "data" CFG_DIR = SPR_ROOT / "cfg" +# ============================================================================ +# Vein Loading +# ============================================================================ + +# Preload pip packages that share names with veins to prevent shadowing +# These must be imported BEFORE any vein that might shadow them +_preloaded_packages = [] +for _pkg in ["google.auth", "jira", "slack_sdk"]: + try: + __import__(_pkg) + _preloaded_packages.append(_pkg) + except ImportError: + pass # Package not installed, vein will fail gracefully + + +def load_vein(vein_name: str): + """ + Load a vein's router dynamically. + + Veins use relative imports for their internal modules (..core, ..models) + and absolute imports for shared utilities (oauth, base from veins/). + + IMPORTANT: Vein folder names may shadow pip packages (e.g., 'google', 'jira'). + We ONLY register under prefixed names (vein_google) to avoid shadowing. + Relative imports work because we set __package__ correctly on each module. + """ + vein_path = SPR_ROOT / "artery" / "veins" / vein_name + if not vein_path.exists(): + raise FileNotFoundError(f"Vein not found: {vein_path}") + + routes_file = vein_path / "api" / "routes.py" + if not routes_file.exists(): + raise FileNotFoundError(f"Vein routes not found: {routes_file}") + + # Use prefixed name to avoid shadowing pip packages + vein_prefix = f"vein_{vein_name}" + + # Clear any previously loaded vein modules to avoid conflicts + for mod_name in list(sys.modules.keys()): + if mod_name == vein_prefix or mod_name.startswith(f"{vein_prefix}."): + del sys.modules[mod_name] + # Clear shared modules that might have stale references + for mod_name in ["oauth", "base"]: + if mod_name in sys.modules: + del sys.modules[mod_name] + + # Add veins directory to path for shared modules (oauth.py, base.py) + veins_path = vein_path.parent + if str(veins_path) not in sys.path: + sys.path.insert(0, str(veins_path)) + + # Create the vein package module + vein_init = vein_path / "__init__.py" + spec = importlib.util.spec_from_file_location( + vein_prefix, + vein_init if vein_init.exists() else None, + submodule_search_locations=[str(vein_path)], + ) + vein_pkg = importlib.util.module_from_spec(spec) + sys.modules[vein_prefix] = vein_pkg + + if spec.loader: + spec.loader.exec_module(vein_pkg) + + # Load subpackages (core, api, models) + for subpkg in ["core", "models", "api"]: + subpkg_path = vein_path / subpkg + if subpkg_path.exists(): + _load_vein_subpackage(vein_pkg, vein_prefix, subpkg, subpkg_path) + + # Load individual modules in core/ and models/ that routes.py needs + for subpkg in ["core", "models"]: + subpkg_path = vein_path / subpkg + if subpkg_path.exists(): + for py_file in subpkg_path.glob("*.py"): + if py_file.name.startswith("_"): + continue + module_name = py_file.stem + _load_vein_module(vein_pkg, vein_prefix, subpkg, module_name, py_file) + + # Now load routes.py with all dependencies available + routes_mod = _load_vein_module(vein_pkg, vein_prefix, "api", "routes", routes_file) + + return routes_mod.router + + +def _load_vein_subpackage(vein_pkg, vein_prefix, subpkg, subpkg_path): + """Load a vein subpackage (core, api, models).""" + subpkg_init = subpkg_path / "__init__.py" + sub_spec = importlib.util.spec_from_file_location( + f"{vein_prefix}.{subpkg}", + subpkg_init if subpkg_init.exists() else None, + submodule_search_locations=[str(subpkg_path)], + ) + sub_mod = importlib.util.module_from_spec(sub_spec) + sys.modules[f"{vein_prefix}.{subpkg}"] = sub_mod + setattr(vein_pkg, subpkg, sub_mod) + + if sub_spec.loader: + sub_spec.loader.exec_module(sub_mod) + + return sub_mod + + +def _load_vein_module(vein_pkg, vein_prefix, subpkg, module_name, file_path): + """Load a specific module within a vein subpackage.""" + mod_spec = importlib.util.spec_from_file_location( + f"{vein_prefix}.{subpkg}.{module_name}", + file_path, + ) + mod = importlib.util.module_from_spec(mod_spec) + + # Set __package__ so relative imports resolve via vein_prefix + mod.__package__ = f"{vein_prefix}.{subpkg}" + + sys.modules[f"{vein_prefix}.{subpkg}.{module_name}"] = mod + + # Also set on parent package + parent_pkg = getattr(vein_pkg, subpkg, None) + if parent_pkg: + setattr(parent_pkg, module_name, mod) + + mod_spec.loader.exec_module(mod) + return mod + + +def mount_veins(app): + """Auto-discover and mount all veins from artery/veins/.""" + veins_dir = SPR_ROOT / "artery" / "veins" + if not veins_dir.exists(): + return + + for vein_path in sorted(veins_dir.iterdir()): + if not vein_path.is_dir(): + continue + if vein_path.name.startswith(("_", ".")): + continue + # Skip non-vein directories (no api/routes.py) + if not (vein_path / "api" / "routes.py").exists(): + continue + + vein_name = vein_path.name + try: + router = load_vein(vein_name) + app.include_router(router, prefix=f"/artery/{vein_name}", tags=[vein_name]) + print(f"Vein mounted: /artery/{vein_name}") + except Exception as e: + print(f"Warning: Could not load vein '{vein_name}': {e}") + + +# ============================================================================ +# Authentication Setup (optional, based on room config) +# ============================================================================ + + +def setup_auth(app, config: dict): + """ + Configure authentication if enabled in room config. + + Auth is optional - rooms without auth config run without authentication. + """ + try: + from common.auth.config import load_auth_config + + auth_config = load_auth_config(config) + if not auth_config: + return + + print(f"Auth: enabled for domains {auth_config.allowed_domains or ['*']}") + + # Get session secret + session_secret = auth_config.session_secret + if session_secret.startswith("ENV:"): + session_secret = os.getenv(session_secret[4:], "dev-secret-change-in-prod") + + # Add session middleware + from starlette.middleware.sessions import SessionMiddleware + + app.add_middleware( + SessionMiddleware, + secret_key=session_secret, + session_cookie="soleprint_session", + max_age=auth_config.session_timeout_hours * 3600, + same_site="lax", + https_only=os.getenv("HTTPS_ONLY", "").lower() == "true", + ) + + # Initialize auth routes + from common.auth.routes import init_auth + from common.auth.routes import router as auth_router + + init_auth(auth_config) + app.include_router(auth_router) + print("Auth: routes mounted at /auth") + + # Add auth middleware + from common.auth.middleware import AuthMiddleware + + app.add_middleware(AuthMiddleware, auth_config=auth_config) + except ImportError: + # common.auth not available (standalone without auth) + pass + except Exception as e: + print(f"Auth setup error: {e}") + + def load_config() -> dict: """Load config.json from cfg/ directory.""" config_path = CFG_DIR / "config.json" @@ -91,6 +298,15 @@ def scan_directory(base_path: Path, pattern: str = "*") -> list[dict]: return items +# ============================================================================ +# Initialize: Load config, setup auth, mount veins +# ============================================================================ + +_config = load_config() +setup_auth(app, _config) +mount_veins(app) + + @app.get("/health") def health(): return { diff --git a/soleprint/station/tools/sbwrapper/sidebar.js b/soleprint/station/tools/sbwrapper/sidebar.js index db938fe..6cc038c 100755 --- a/soleprint/station/tools/sbwrapper/sidebar.js +++ b/soleprint/station/tools/sbwrapper/sidebar.js @@ -1,6 +1,6 @@ -// Pawprint Wrapper - Sidebar Logic +// Soleprint Wrapper - Sidebar Logic -class PawprintSidebar { +class SoleprintSidebar { constructor() { this.config = null; this.currentUser = null; @@ -28,38 +28,38 @@ class PawprintSidebar { async loadConfig() { try { - const response = await fetch('/wrapper/config.json'); + const response = await fetch("/wrapper/config.json"); this.config = await response.json(); - console.log('[Pawprint] Config loaded:', this.config.nest_name); + console.log("[Soleprint] Config loaded:", this.config.nest_name); } catch (error) { - console.error('[Pawprint] Failed to load config:', error); + console.error("[Soleprint] Failed to load config:", error); // Use default config this.config = { - nest_name: 'default', + nest_name: "default", wrapper: { environment: { - backend_url: 'http://localhost:8000', - frontend_url: 'http://localhost:3000' + backend_url: "http://localhost:8000", + frontend_url: "http://localhost:3000", }, - users: [] - } + users: [], + }, }; } } createSidebar() { - const sidebar = document.createElement('div'); - sidebar.id = 'pawprint-sidebar'; + const sidebar = document.createElement("div"); + sidebar.id = "pawprint-sidebar"; sidebar.innerHTML = this.getSidebarHTML(); document.body.appendChild(sidebar); this.sidebar = sidebar; } createToggleButton() { - const button = document.createElement('button'); - button.id = 'sidebar-toggle'; + const button = document.createElement("button"); + button.id = "sidebar-toggle"; button.innerHTML = ''; - button.title = 'Toggle Pawprint Sidebar (Ctrl+Shift+P)'; + button.title = "Toggle Soleprint Sidebar (Ctrl+Shift+P)"; document.body.appendChild(button); this.toggleBtn = button; } @@ -69,7 +69,7 @@ class PawprintSidebar { return ` @@ -83,22 +83,26 @@ class PawprintSidebar {
- ${users.map(user => ` -
+ ${users + .map( + (user) => ` +
${user.icon}
${user.label} ${user.role}
- `).join('')} + `, + ) + .join("")}
@@ -119,18 +123,18 @@ class PawprintSidebar { `; } setupEventListeners() { // Toggle button - this.toggleBtn.addEventListener('click', () => this.toggle()); + this.toggleBtn.addEventListener("click", () => this.toggle()); // Keyboard shortcut: Ctrl+Shift+P - document.addEventListener('keydown', (e) => { - if (e.ctrlKey && e.shiftKey && e.key === 'P') { + document.addEventListener("keydown", (e) => { + if (e.ctrlKey && e.shiftKey && e.key === "P") { e.preventDefault(); this.toggle(); } @@ -138,28 +142,29 @@ class PawprintSidebar { } toggle() { - this.sidebar.classList.toggle('expanded'); + this.sidebar.classList.toggle("expanded"); this.saveSidebarState(); } saveSidebarState() { - const isExpanded = this.sidebar.classList.contains('expanded'); - localStorage.setItem('pawprint_sidebar_expanded', isExpanded); + const isExpanded = this.sidebar.classList.contains("expanded"); + localStorage.setItem("pawprint_sidebar_expanded", isExpanded); } loadSidebarState() { - const isExpanded = localStorage.getItem('pawprint_sidebar_expanded') === 'true'; + const isExpanded = + localStorage.getItem("pawprint_sidebar_expanded") === "true"; if (isExpanded) { - this.sidebar.classList.add('expanded'); + this.sidebar.classList.add("expanded"); } } - showStatus(message, type = 'info') { - const container = document.getElementById('status-container'); - const statusDiv = document.createElement('div'); + showStatus(message, type = "info") { + const container = document.getElementById("status-container"); + const statusDiv = document.createElement("div"); statusDiv.className = `status-message ${type}`; statusDiv.textContent = message; - container.innerHTML = ''; + container.innerHTML = ""; container.appendChild(statusDiv); // Auto-remove after 5 seconds @@ -169,22 +174,22 @@ class PawprintSidebar { } async loginAs(userId) { - const user = this.config.wrapper.users.find(u => u.id === userId); + const user = this.config.wrapper.users.find((u) => u.id === userId); if (!user) return; - this.showStatus(`Logging in as ${user.label}... ⏳`, 'info'); + this.showStatus(`Logging in as ${user.label}... ⏳`, "info"); try { const backendUrl = this.config.wrapper.environment.backend_url; const response = await fetch(`${backendUrl}/api/token/`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ username: user.username, - password: user.password - }) + password: user.password, + }), }); if (!response.ok) { @@ -194,17 +199,20 @@ class PawprintSidebar { const data = await response.json(); // Store tokens - localStorage.setItem('access_token', data.access); - localStorage.setItem('refresh_token', data.refresh); + localStorage.setItem("access_token", data.access); + localStorage.setItem("refresh_token", data.refresh); // Store user info - localStorage.setItem('user_info', JSON.stringify({ - username: user.username, - label: user.label, - role: data.details?.role || user.role - })); + localStorage.setItem( + "user_info", + JSON.stringify({ + username: user.username, + label: user.label, + role: data.details?.role || user.role, + }), + ); - this.showStatus(`✓ Logged in as ${user.label}`, 'success'); + this.showStatus(`✓ Logged in as ${user.label}`, "success"); this.currentUser = user; this.updateCurrentUserDisplay(); @@ -212,19 +220,18 @@ class PawprintSidebar { setTimeout(() => { window.location.reload(); }, 1000); - } catch (error) { - console.error('[Pawprint] Login error:', error); - this.showStatus(`✗ Login failed: ${error.message}`, 'error'); + console.error("[Soleprint] Login error:", error); + this.showStatus(`✗ Login failed: ${error.message}`, "error"); } } logout() { - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); - localStorage.removeItem('user_info'); + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + localStorage.removeItem("user_info"); - this.showStatus('✓ Logged out', 'success'); + this.showStatus("✓ Logged out", "success"); this.currentUser = null; this.updateCurrentUserDisplay(); @@ -235,52 +242,54 @@ class PawprintSidebar { } checkCurrentUser() { - const userInfo = localStorage.getItem('user_info'); + const userInfo = localStorage.getItem("user_info"); if (userInfo) { try { this.currentUser = JSON.parse(userInfo); this.updateCurrentUserDisplay(); } catch (error) { - console.error('[Pawprint] Failed to parse user info:', error); + console.error("[Soleprint] Failed to parse user info:", error); } } } updateCurrentUserDisplay() { - const display = document.getElementById('current-user-display'); - const username = document.getElementById('current-username'); + const display = document.getElementById("current-user-display"); + const username = document.getElementById("current-username"); if (this.currentUser) { - display.style.display = 'block'; + display.style.display = "block"; username.textContent = this.currentUser.username; // Highlight active user card - document.querySelectorAll('.user-card').forEach(card => { - card.classList.remove('active'); + document.querySelectorAll(".user-card").forEach((card) => { + card.classList.remove("active"); }); - const activeCard = document.querySelector(`.user-card[data-user-id="${this.getUserIdByUsername(this.currentUser.username)}"]`); + const activeCard = document.querySelector( + `.user-card[data-user-id="${this.getUserIdByUsername(this.currentUser.username)}"]`, + ); if (activeCard) { - activeCard.classList.add('active'); + activeCard.classList.add("active"); } } else { - display.style.display = 'none'; + display.style.display = "none"; } } getUserIdByUsername(username) { - const user = this.config.wrapper.users.find(u => u.username === username); + const user = this.config.wrapper.users.find((u) => u.username === username); return user ? user.id : null; } } // Initialize sidebar when DOM is ready -const pawprintSidebar = new PawprintSidebar(); +const soleprintSidebar = new SoleprintSidebar(); -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => pawprintSidebar.init()); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => soleprintSidebar.init()); } else { - pawprintSidebar.init(); + soleprintSidebar.init(); } -console.log('[Pawprint] Sidebar script loaded'); +console.log("[Soleprint] Sidebar script loaded");