Add OAuth SSO, device ID, and silent auth to L2
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 8m4s

- Replace L2's username/password auth with OAuth SSO via account.rose-ash.com
- Add device_id middleware (artdag_did cookie)
- Add silent auth check (prompt=none with 5-min cooldown)
- Add OAuth config settings and itsdangerous dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-24 23:50:31 +00:00
parent d8206c7b3b
commit 3dde4e79ab
5 changed files with 227 additions and 191 deletions

View File

@@ -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")

View File

@@ -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")

View File

@@ -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},
@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}"
templates = get_templates(request)
return render(templates, "auth/login.html", request,
return_to=return_to,
)
@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(
'<div class="text-red-400">Username and password are required</div>'
)
user = await authenticate_user(settings.data_dir, username.strip(), password)
if not user:
return HTMLResponse(
'<div class="text-red-400">Invalid username or password</div>'
)
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'''
<div class="text-green-400">Login successful! Redirecting...</div>
<script>window.location.href = "{redirect_url}";</script>
''')
else:
response = HTMLResponse('''
<div class="text-green-400">Login successful! Redirecting...</div>
<script>window.location.href = "/";</script>
''')
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('<div class="text-red-400">Username and password are required</div>')
if password != password2:
return HTMLResponse('<div class="text-red-400">Passwords do not match</div>')
if len(password) < 6:
return HTMLResponse('<div class="text-red-400">Password must be at least 6 characters</div>')
# 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'<div class="text-red-400">{str(e)}</div>')
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('''
<div class="text-green-400">Registration successful! Redirecting...</div>
<script>window.location.href = "/";</script>
''')
# 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="auth_token",
value=token.access_token,
key="pnone_at",
value=str(time.time()),
max_age=300,
httponly=True,
max_age=60 * 60 * 24 * 30,
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,
}

View File

@@ -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

View File

@@ -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