Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ __pycache__/

# C extensions
*.so
.env

certs

Expand Down
46 changes: 45 additions & 1 deletion auth/cognito/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,38 @@ def from_env(cls) -> "CognitoAuth":
)
return cls(config)

@classmethod
def from_settings(cls, auth_settings) -> "CognitoAuth":
"""Create CognitoAuth from BloomSettings.auth instance.

Args:
auth_settings: AuthSettings instance from bloom_lims.config
"""
# Validate required settings
required = {
"cognito_user_pool_id": auth_settings.cognito_user_pool_id,
"cognito_client_id": auth_settings.cognito_client_id,
"cognito_region": auth_settings.cognito_region,
"cognito_domain": auth_settings.cognito_domain,
"cognito_redirect_uri": auth_settings.cognito_redirect_uri,
}
missing = [name for name, value in required.items() if not value]
if missing:
raise CognitoConfigurationError(
f"Missing Cognito configuration in YAML config: {', '.join(sorted(missing))}"
)

config = CognitoConfig(
user_pool_id=auth_settings.cognito_user_pool_id,
client_id=auth_settings.cognito_client_id,
region=auth_settings.cognito_region,
domain=auth_settings.cognito_domain,
redirect_uri=auth_settings.cognito_redirect_uri,
logout_redirect_uri=auth_settings.cognito_logout_redirect_uri or auth_settings.cognito_redirect_uri,
scopes=auth_settings.cognito_scopes or ["openid", "email", "profile"],
)
return cls(config)

def validate_token(self, token: str) -> Dict:
try:
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
Expand All @@ -115,5 +147,17 @@ def validate_token(self, token: str) -> Dict:

@lru_cache(maxsize=1)
def get_cognito_auth() -> CognitoAuth:
"""Create (and cache) a CognitoAuth instance from environment variables."""
"""Create (and cache) a CognitoAuth instance from YAML config.

Falls back to environment variables if YAML config is incomplete.
"""
try:
from bloom_lims.config import get_settings
settings = get_settings()
# Check if YAML config has Cognito settings
if settings.auth.cognito_user_pool_id and settings.auth.cognito_client_id:
return CognitoAuth.from_settings(settings.auth)
except Exception:
pass
# Fall back to environment variables
return CognitoAuth.from_env()
3 changes: 2 additions & 1 deletion bloom_lims/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

# Import subcommand groups
from bloom_lims.cli.db import db
from bloom_lims.cli.gui import gui
from bloom_lims.cli.gui import gui, stop
from bloom_lims.cli.info import info, status, doctor, version
from bloom_lims.cli.config_cmd import config
from bloom_lims.cli.utils import shell, logs
Expand Down Expand Up @@ -60,6 +60,7 @@ def cli(ctx, verbose, show_version):
# Register command groups
cli.add_command(db)
cli.add_command(gui)
cli.add_command(stop)
cli.add_command(info)
cli.add_command(status)
cli.add_command(doctor)
Expand Down
137 changes: 109 additions & 28 deletions bloom_lims/cli/gui.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""GUI/Web UI commands for BLOOM CLI."""

import os
import signal
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path

import click
Expand All @@ -13,44 +16,122 @@
# Get project root
PROJECT_ROOT = Path(__file__).parent.parent.parent

# PID and log file locations
CONFIG_DIR = Path.home() / ".bloom"
LOG_DIR = CONFIG_DIR / "logs"
PID_FILE = CONFIG_DIR / "server.pid"


def _ensure_dir():
"""Ensure .bloom directories exist."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)


def _get_log_file() -> Path:
"""Get timestamped log file path."""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
return LOG_DIR / f"server_{ts}.log"


def _get_latest_log() -> Path | None:
"""Get the most recent log file."""
logs = sorted(LOG_DIR.glob("server_*.log"), reverse=True)
return logs[0] if logs else None


def _get_pid() -> int | None:
"""Get the running server PID if exists."""
if PID_FILE.exists():
try:
pid = int(PID_FILE.read_text().strip())
os.kill(pid, 0)
return pid
except (ValueError, ProcessLookupError, PermissionError):
PID_FILE.unlink(missing_ok=True)
return None


@click.command()
@click.option('--port', '-p', default=8080, type=int, help='Port to run on (default: 8080)')
@click.option('--port', '-p', default=8911, type=int, help='Port to run on (default: 8911)')
@click.option('--host', default='0.0.0.0', help='Host to bind to (default: 0.0.0.0)')
@click.option('--reload', '-r', is_flag=True, help='Enable auto-reload for development')
@click.option('--https', is_flag=True, help='Enable HTTPS (requires certs in certs/)')
def gui(port, host, reload, https):
@click.option('--background/--foreground', '-b/-f', default=True, help='Run in background (default)')
def gui(port, host, reload, https, background):
"""Start the BLOOM web UI."""
console.print(f"[cyan]Starting BLOOM UI on {host}:{port}...[/cyan]")

# Build uvicorn command
cmd = [
sys.executable, "-m", "uvicorn",
"main:app",
"--host", host,
"--port", str(port),
]

_ensure_dir()

pid = _get_pid()
if pid:
protocol = "https" if https else "http"
console.print(f"[yellow]⚠[/yellow] Server already running (PID {pid})")
console.print(f" URL: [cyan]{protocol}://{host}:{port}[/cyan]")
console.print(" Use [cyan]bloom stop[/cyan] to stop or [cyan]bloom logs[/cyan] to view logs")
return

cmd = [sys.executable, "-m", "uvicorn", "main:app", "--host", host, "--port", str(port)]
if reload:
cmd.append("--reload")

if https:
certs_dir = PROJECT_ROOT / "certs"
key_file = certs_dir / "key.pem"
cert_file = certs_dir / "cert.pem"

if not key_file.exists() or not cert_file.exists():
if not (certs_dir / "key.pem").exists():
console.print("[red]✗[/red] HTTPS certificates not found in certs/")
console.print(" Generate with: [cyan]mkcert -key-file certs/key.pem -cert-file certs/cert.pem localhost 127.0.0.1[/cyan]")
raise SystemExit(1)

cmd.extend(["--ssl-keyfile", str(key_file), "--ssl-certfile", str(cert_file)])
console.print(f" HTTPS enabled")

console.print(f" URL: [cyan]{'https' if https else 'http'}://{host}:{port}[/cyan]")
console.print()

# Run uvicorn
os.chdir(PROJECT_ROOT)
os.execvp(sys.executable, cmd)
cmd.extend(["--ssl-keyfile", str(certs_dir / "key.pem"), "--ssl-certfile", str(certs_dir / "cert.pem")])

protocol = "https" if https else "http"
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"

if background:
log_file = _get_log_file()
with open(log_file, "w", buffering=1) as log_f:
proc = subprocess.Popen(cmd, stdout=log_f, stderr=subprocess.STDOUT, start_new_session=True, cwd=PROJECT_ROOT, env=env)
time.sleep(2)
if proc.poll() is not None:
console.print("[red]✗[/red] Server failed to start. Check logs:")
console.print(f" [dim]{log_file}[/dim]")
raise SystemExit(1)
PID_FILE.write_text(str(proc.pid))
console.print(f"[green]✓[/green] Server started (PID {proc.pid})")
console.print(f" URL: [cyan]{protocol}://{host}:{port}[/cyan]")
console.print(f" Logs: [dim]{log_file}[/dim]")
console.print(" Use [cyan]bloom logs[/cyan] to view logs or [cyan]bloom stop[/cyan] to stop")
else:
console.print(f"[cyan]Starting BLOOM UI on {host}:{port}...[/cyan]")
console.print(f" URL: [cyan]{protocol}://{host}:{port}[/cyan]")
console.print(" Press Ctrl+C to stop\n")
os.chdir(PROJECT_ROOT)
try:
subprocess.run(cmd, env=env)
except KeyboardInterrupt:
console.print("\n[yellow]⚠[/yellow] Server stopped")


@click.command()
def stop():
"""Stop the BLOOM web UI."""
pid = _get_pid()
if not pid:
console.print("[yellow]⚠[/yellow] No server running")
return
try:
os.kill(pid, signal.SIGTERM)
for _ in range(10):
time.sleep(0.5)
try:
os.kill(pid, 0)
except ProcessLookupError:
break
else:
os.kill(pid, signal.SIGKILL)
PID_FILE.unlink(missing_ok=True)
console.print(f"[green]✓[/green] Server stopped (was PID {pid})")
except ProcessLookupError:
PID_FILE.unlink(missing_ok=True)
console.print("[yellow]⚠[/yellow] Server was not running")
except PermissionError:
console.print(f"[red]✗[/red] Permission denied stopping PID {pid}")
raise SystemExit(1)

11 changes: 10 additions & 1 deletion bloom_lims/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# Get project root
PROJECT_ROOT = Path(__file__).parent.parent.parent
PGDATA = PROJECT_ROOT / "bloom_lims" / "database"
SERVER_LOG_DIR = Path.home() / ".bloom" / "logs"


@click.command()
Expand Down Expand Up @@ -59,12 +60,19 @@ def shell():
@click.command()
@click.option('--lines', '-n', default=50, type=int, help='Number of lines to show')
@click.option('--follow', '-f', is_flag=True, help='Follow log output')
@click.option('--service', '-s', type=click.Choice(['postgres', 'all']), default='all',
@click.option('--service', '-s', type=click.Choice(['server', 'postgres', 'all']), default='all',
help='Service to show logs for')
def logs(lines, follow, service):
"""View BLOOM service logs."""
log_files = []

# Check for server logs
if service in ['server', 'all']:
if SERVER_LOG_DIR.exists():
server_logs = sorted(SERVER_LOG_DIR.glob("server_*.log"), reverse=True)
if server_logs:
log_files.append(('Server', server_logs[0]))

if service in ['postgres', 'all']:
pg_log = PGDATA / "postgresql.log"
if pg_log.exists():
Expand All @@ -74,6 +82,7 @@ def logs(lines, follow, service):
console.print("[yellow]No log files found.[/yellow]")
console.print()
console.print("Logs are available for:")
console.print(" • Server: [cyan]~/.bloom/logs/server_*.log[/cyan]")
console.print(" • PostgreSQL: [cyan]bloom_lims/database/postgresql.log[/cyan]")
return

Expand Down
70 changes: 68 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,33 @@ def __init__(self, message="The Cognito environment variables are not found."):


def get_allowed_domains() -> List[str]:
"""Get allowed email domains from environment.
"""Get allowed email domains from YAML config or environment.

Returns:
Empty list [] = allow all domains
List with domains = only those domains allowed
List with ["__BLOCK_ALL__"] = block all domains (when env var is empty string)
List with ["__BLOCK_ALL__"] = block all domains (when config is empty)
"""
try:
from bloom_lims.config import get_settings
settings = get_settings()
domains = settings.auth.cognito_allowed_domains

# If YAML config has domains, use them
if domains:
# ["*"] = allow all
if domains == ["*"]:
return []
return domains

# Empty list in YAML = block all
# Check if it was explicitly set (vs default)
# For now, empty list blocks all (consistent with atlas/ursa)
return ["__BLOCK_ALL__"]
Comment thread
iamh2o marked this conversation as resolved.
Resolved
except Exception:
pass

# Fall back to environment variable
whitelist_domains = os.getenv("COGNITO_WHITELIST_DOMAINS", "all")

# Empty string = block all domains
Expand Down Expand Up @@ -446,6 +466,52 @@ async def get_login_page(request: Request):
return HTMLResponse(content=template.render(context))


@app.get("/oauth_callback")
async def oauth_callback_get(request: Request):
"""Handle OAuth implicit flow callback - parse URL fragment and POST tokens."""
html_content = """<!DOCTYPE html>
<html>
<head><title>Processing login...</title></head>
<body>
<p>Processing your login...</p>
<script>
const fragment = new URLSearchParams(window.location.hash.substring(1));
const accessToken = fragment.get('access_token');
const idToken = fragment.get('id_token');
const error = fragment.get('error');
const errorDescription = fragment.get('error_description');

if (error) {
window.location.href = '/login?error=' + encodeURIComponent(errorDescription || error);
} else if (accessToken || idToken) {
fetch('/oauth_callback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({access_token: accessToken, id_token: idToken}),
credentials: 'same-origin'
}).then(response => {
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.href = '/';
} else {
return response.json().then(data => {
window.location.href = '/login?error=' + encodeURIComponent(data.detail || 'Authentication failed');
});
}
}).catch(err => {
console.error('Error:', err);
window.location.href = '/login?error=authentication_failed';
});
} else {
window.location.href = '/login?error=no_tokens';
}
</script>
</body>
</html>"""
return HTMLResponse(content=html_content)


@app.post("/oauth_callback")
async def oauth_callback(request: Request):
body = await request.json()
Expand Down
Loading