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