diff --git a/l2/app/__init__.py b/l2/app/__init__.py index add533b..e4938e2 100644 --- a/l2/app/__init__.py +++ b/l2/app/__init__.py @@ -4,16 +4,38 @@ Art-DAG L2 Server Application Factory. Creates and configures the FastAPI application with all routers and middleware. """ +import secrets +import time from pathlib import Path from contextlib import asynccontextmanager +from urllib.parse import quote + from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse, HTMLResponse +from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse from artdag_common import create_jinja_env from artdag_common.middleware.auth import get_user_from_cookie from .config import settings +# Paths that should never trigger a silent auth check +_SKIP_PREFIXES = ("/auth/", "/.well-known/", "/health", + "/internal/", "/static/", "/inbox") +_SILENT_CHECK_COOLDOWN = 300 # 5 minutes +_DEVICE_COOKIE = "artdag_did" +_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days + +# Derive external base URL from oauth_redirect_uri (e.g. https://artdag.rose-ash.com) +_EXTERNAL_BASE = settings.oauth_redirect_uri.rsplit("/auth/callback", 1)[0] + + +def _external_url(request: Request) -> str: + """Build external URL from request path + query, using configured base domain.""" + url = f"{_EXTERNAL_BASE}{request.url.path}" + if request.url.query: + url += f"?{request.url.query}" + return url + @asynccontextmanager async def lifespan(app: FastAPI): @@ -38,6 +60,64 @@ def create_app() -> FastAPI: lifespan=lifespan, ) + # Silent auth check — auto-login via prompt=none OAuth + # NOTE: registered BEFORE device_id so device_id is outermost (runs first) + @app.middleware("http") + async def silent_auth_check(request: Request, call_next): + path = request.url.path + if ( + request.method != "GET" + or any(path.startswith(p) for p in _SKIP_PREFIXES) + or request.headers.get("hx-request") # skip HTMX + ): + return await call_next(request) + + # Already logged in — pass through + if get_user_from_cookie(request): + return await call_next(request) + + # Check cooldown — don't re-check within 5 minutes + pnone_at = request.cookies.get("pnone_at") + if pnone_at: + try: + pnone_ts = float(pnone_at) + if (time.time() - pnone_ts) < _SILENT_CHECK_COOLDOWN: + return await call_next(request) + except (ValueError, TypeError): + pass + + # Redirect to silent OAuth check + current_url = _external_url(request) + return RedirectResponse( + url=f"/auth/login?prompt=none&next={quote(current_url, safe='')}", + status_code=302, + ) + + # Device ID middleware — track browser identity across domains + # Registered AFTER silent_auth_check so it's outermost (always runs) + @app.middleware("http") + async def device_id_middleware(request: Request, call_next): + did = request.cookies.get(_DEVICE_COOKIE) + if did: + request.state.device_id = did + request.state._new_device_id = False + else: + request.state.device_id = secrets.token_urlsafe(32) + request.state._new_device_id = True + + response = await call_next(request) + + if getattr(request.state, "_new_device_id", False): + response.set_cookie( + key=_DEVICE_COOKIE, + value=request.state.device_id, + max_age=_DEVICE_COOKIE_MAX_AGE, + httponly=True, + samesite="lax", + secure=True, + ) + return response + # Coop fragment pre-fetch — inject nav-tree, auth-menu, cart-mini _FRAG_SKIP = ("/auth/", "/.well-known/", "/health", "/internal/", "/static/", "/inbox") diff --git a/l2/app/config.py b/l2/app/config.py index d88d435..d08b3ed 100644 --- a/l2/app/config.py +++ b/l2/app/config.py @@ -33,6 +33,14 @@ class Settings: jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 24 * 30 # 30 days + # OAuth SSO (via account.rose-ash.com) + oauth_authorize_url: str = os.environ.get("OAUTH_AUTHORIZE_URL", "https://account.rose-ash.com/auth/oauth/authorize") + oauth_token_url: str = os.environ.get("OAUTH_TOKEN_URL", "https://account.rose-ash.com/auth/oauth/token") + oauth_client_id: str = os.environ.get("OAUTH_CLIENT_ID", "artdag_l2") + oauth_redirect_uri: str = os.environ.get("OAUTH_REDIRECT_URI", "https://artdag.rose-ash.com/auth/callback") + oauth_logout_url: str = os.environ.get("OAUTH_LOGOUT_URL", "https://account.rose-ash.com/auth/sso-logout/") + secret_key: str = os.environ.get("SECRET_KEY", "change-me-in-production") + def __post_init__(self): # Parse L1 servers l1_str = os.environ.get("L1_SERVERS", "https://celery-artdag.rose-ash.com") diff --git a/l2/app/routers/auth.py b/l2/app/routers/auth.py index 4691caf..98fea5a 100644 --- a/l2/app/routers/auth.py +++ b/l2/app/routers/auth.py @@ -1,223 +1,164 @@ """ -Authentication routes for L2 server. +Authentication routes — OAuth2 authorization code flow via account.rose-ash.com. -Handles login, registration, logout, and token verification. +GET /auth/login — redirect to account OAuth authorize +GET /auth/callback — exchange code for user info, set session cookie +GET /auth/logout — clear cookie, redirect through account SSO logout """ -import hashlib -from datetime import datetime, timezone +import secrets +import time -from fastapi import APIRouter, Request, Form, HTTPException, Depends -from fastapi.responses import HTMLResponse, RedirectResponse -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import httpx +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from itsdangerous import URLSafeSerializer -from artdag_common import render -from artdag_common.middleware import wants_html +from artdag_common.middleware.auth import UserContext, set_auth_cookie, clear_auth_cookie from ..config import settings -from ..dependencies import get_templates, get_user_from_cookie router = APIRouter() -security = HTTPBearer(auto_error=False) + +_signer = None -@router.get("/login", response_class=HTMLResponse) -async def login_page(request: Request, return_to: str = None): - """Login page.""" - username = get_user_from_cookie(request) +def _get_signer() -> URLSafeSerializer: + global _signer + if _signer is None: + _signer = URLSafeSerializer(settings.secret_key, salt="oauth-state") + return _signer - if username: - templates = get_templates(request) - return render(templates, "auth/already_logged_in.html", request, - user={"username": username}, - ) - templates = get_templates(request) - return render(templates, "auth/login.html", request, - return_to=return_to, +@router.get("/login") +async def login(request: Request): + """Store state + next in signed cookie, redirect to account OAuth authorize.""" + next_url = request.query_params.get("next", "/") + prompt = request.query_params.get("prompt", "") + state = secrets.token_urlsafe(32) + + signer = _get_signer() + state_payload = signer.dumps({"state": state, "next": next_url, "prompt": prompt}) + + device_id = getattr(request.state, "device_id", "") + authorize_url = ( + f"{settings.oauth_authorize_url}" + f"?client_id={settings.oauth_client_id}" + f"&redirect_uri={settings.oauth_redirect_uri}" + f"&device_id={device_id}" + f"&state={state}" ) + if prompt: + authorize_url += f"&prompt={prompt}" - -@router.post("/login", response_class=HTMLResponse) -async def login_submit( - request: Request, - username: str = Form(...), - password: str = Form(...), - return_to: str = Form(None), -): - """Handle login form submission.""" - from auth import authenticate_user, create_access_token - - if not username or not password: - return HTMLResponse( - '
Username and password are required
' - ) - - user = await authenticate_user(settings.data_dir, username.strip(), password) - if not user: - return HTMLResponse( - '
Invalid username or password
' - ) - - token = create_access_token(user.username, l2_server=f"https://{settings.domain}") - - # Handle return_to redirect - if return_to and return_to.startswith("http"): - separator = "&" if "?" in return_to else "?" - redirect_url = f"{return_to}{separator}auth_token={token.access_token}" - response = HTMLResponse(f''' -
Login successful! Redirecting...
- - ''') - else: - response = HTMLResponse(''' -
Login successful! Redirecting...
- - ''') - + response = RedirectResponse(url=authorize_url, status_code=302) response.set_cookie( - key="auth_token", - value=token.access_token, + key="oauth_state", + value=state_payload, + max_age=600, # 10 minutes httponly=True, - max_age=60 * 60 * 24 * 30, samesite="lax", secure=True, ) return response -@router.get("/register", response_class=HTMLResponse) -async def register_page(request: Request): - """Registration page.""" - username = get_user_from_cookie(request) +@router.get("/callback") +async def callback(request: Request): + """Validate state, exchange code via token endpoint, set session cookie.""" + code = request.query_params.get("code", "") + state = request.query_params.get("state", "") + error = request.query_params.get("error", "") + account_did = request.query_params.get("account_did", "") - if username: - templates = get_templates(request) - return render(templates, "auth/already_logged_in.html", request, - user={"username": username}, - ) - - templates = get_templates(request) - return render(templates, "auth/register.html", request) - - -@router.post("/register", response_class=HTMLResponse) -async def register_submit( - request: Request, - username: str = Form(...), - password: str = Form(...), - password2: str = Form(...), - email: str = Form(None), -): - """Handle registration form submission.""" - from auth import create_user, create_access_token - - if not username or not password: - return HTMLResponse('
Username and password are required
') - - if password != password2: - return HTMLResponse('
Passwords do not match
') - - if len(password) < 6: - return HTMLResponse('
Password must be at least 6 characters
') + # Adopt account's device ID as our own (one identity across all apps) + if account_did: + request.state.device_id = account_did + request.state._new_device_id = True # device_id middleware will set cookie + # Recover state from signed cookie + state_cookie = request.cookies.get("oauth_state", "") + signer = _get_signer() try: - user = await create_user(settings.data_dir, username.strip(), password, email) - except ValueError as e: - return HTMLResponse(f'
{str(e)}
') + payload = signer.loads(state_cookie) if state_cookie else {} + except Exception: + payload = {} - token = create_access_token(user.username, l2_server=f"https://{settings.domain}") + next_url = payload.get("next", "/") - response = HTMLResponse(''' -
Registration successful! Redirecting...
- - ''') - response.set_cookie( - key="auth_token", - value=token.access_token, - httponly=True, - max_age=60 * 60 * 24 * 30, - samesite="lax", - secure=True, - ) + # Handle prompt=none rejection (user not logged in on account) + if error == "login_required": + response = RedirectResponse(url=next_url, status_code=302) + response.delete_cookie("oauth_state") + # Set cooldown cookie — don't re-check for 5 minutes + response.set_cookie( + key="pnone_at", + value=str(time.time()), + max_age=300, + httponly=True, + samesite="lax", + secure=True, + ) + # Set device cookie if adopted + if account_did: + response.set_cookie( + key="artdag_did", + value=account_did, + max_age=30 * 24 * 3600, + httponly=True, + samesite="lax", + secure=True, + ) + return response + + # Normal callback — validate state + code + if not state_cookie or not code or not state: + return RedirectResponse(url="/", status_code=302) + + if payload.get("state") != state: + return RedirectResponse(url="/", status_code=302) + + # Exchange code for user info via account's token endpoint + async with httpx.AsyncClient(timeout=10) as client: + try: + resp = await client.post( + settings.oauth_token_url, + json={ + "code": code, + "client_id": settings.oauth_client_id, + "redirect_uri": settings.oauth_redirect_uri, + }, + ) + except httpx.HTTPError: + return RedirectResponse(url="/", status_code=302) + + if resp.status_code != 200: + return RedirectResponse(url="/", status_code=302) + + data = resp.json() + if "error" in data: + return RedirectResponse(url="/", status_code=302) + + # Map OAuth response to artdag UserContext + # Note: account token endpoint returns user.email as "username" + username = data.get("username", "") + email = username # OAuth response "username" is the user's email + actor_id = f"@{username}" + + user = UserContext(username=username, actor_id=actor_id, email=email) + + response = RedirectResponse(url=next_url, status_code=302) + set_auth_cookie(response, user) + response.delete_cookie("oauth_state") + response.delete_cookie("pnone_at") return response @router.get("/logout") -async def logout(request: Request): - """Handle logout.""" - import db - import requests - from auth import get_token_claims - - token = request.cookies.get("auth_token") - claims = get_token_claims(token) if token else None - username = claims.get("sub") if claims else None - - if username and token and claims: - # Revoke token in database - token_hash = hashlib.sha256(token.encode()).hexdigest() - expires_at = datetime.fromtimestamp(claims.get("exp", 0), tz=timezone.utc) - await db.revoke_token(token_hash, username, expires_at) - - # Revoke on attached L1 servers - attached = await db.get_user_renderers(username) - for l1_url in attached: - try: - requests.post( - f"{l1_url}/auth/revoke-user", - json={"username": username, "l2_server": f"https://{settings.domain}"}, - timeout=5, - ) - except Exception: - pass - - response = RedirectResponse(url="/", status_code=302) - response.delete_cookie("auth_token") +async def logout(): + """Clear session cookie, redirect through account SSO logout.""" + response = RedirectResponse(url=settings.oauth_logout_url, status_code=302) + clear_auth_cookie(response) + response.delete_cookie("oauth_state") + response.delete_cookie("pnone_at") return response - - -@router.get("/verify") -async def verify_token( - request: Request, - credentials: HTTPAuthorizationCredentials = Depends(security), -): - """ - Verify a token is valid. - - Called by L1 servers to verify tokens during auth callback. - Returns user info if valid, 401 if not. - """ - import db - from auth import verify_token as verify_jwt, get_token_claims - - # Get token from Authorization header or query param - token = None - if credentials: - token = credentials.credentials - else: - # Try Authorization header manually (for clients that don't use Bearer format) - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - token = auth_header[7:] - - if not token: - raise HTTPException(401, "No token provided") - - # Verify JWT signature and expiry - username = verify_jwt(token) - if not username: - raise HTTPException(401, "Invalid or expired token") - - # Check if token is revoked - claims = get_token_claims(token) - if claims: - token_hash = hashlib.sha256(token.encode()).hexdigest() - if await db.is_token_revoked(token_hash): - raise HTTPException(401, "Token has been revoked") - - return { - "valid": True, - "username": username, - "claims": claims, - } diff --git a/l2/docker-compose.yml b/l2/docker-compose.yml index 0f67e81..9c91ea4 100644 --- a/l2/docker-compose.yml +++ b/l2/docker-compose.yml @@ -52,7 +52,13 @@ services: - INTERNAL_URL_BLOG=http://blog:8000 - INTERNAL_URL_CART=http://cart:8000 - INTERNAL_URL_ACCOUNT=http://account:8000 - # DATABASE_URL, ARTDAG_DOMAIN, ARTDAG_USER, JWT_SECRET from .env file + # OAuth SSO + - OAUTH_AUTHORIZE_URL=https://account.rose-ash.com/auth/oauth/authorize + - OAUTH_TOKEN_URL=https://account.rose-ash.com/auth/oauth/token + - OAUTH_CLIENT_ID=artdag_l2 + - OAUTH_REDIRECT_URI=https://artdag.rose-ash.com/auth/callback + - OAUTH_LOGOUT_URL=https://account.rose-ash.com/auth/sso-logout/ + # DATABASE_URL, ARTDAG_DOMAIN, ARTDAG_USER, JWT_SECRET, SECRET_KEY from .env file healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8200/')"] interval: 10s diff --git a/l2/requirements.txt b/l2/requirements.txt index 5d228c5..83b95d2 100644 --- a/l2/requirements.txt +++ b/l2/requirements.txt @@ -7,6 +7,7 @@ bcrypt>=4.0.0 python-jose[cryptography]>=3.3.0 markdown>=3.5.0 python-multipart>=0.0.6 +itsdangerous>=2.1.0 asyncpg>=0.29.0 boto3>=1.34.0 # common (artdag_common) installed from local dir in Dockerfile