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. Creates and configures the FastAPI application with all routers and middleware.
""" """
import secrets
import time
from pathlib import Path from pathlib import Path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from urllib.parse import quote
from fastapi import FastAPI, Request 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 import create_jinja_env
from artdag_common.middleware.auth import get_user_from_cookie from artdag_common.middleware.auth import get_user_from_cookie
from .config import settings 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -38,6 +60,64 @@ def create_app() -> FastAPI:
lifespan=lifespan, 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 # Coop fragment pre-fetch — inject nav-tree, auth-menu, cart-mini
_FRAG_SKIP = ("/auth/", "/.well-known/", "/health", _FRAG_SKIP = ("/auth/", "/.well-known/", "/health",
"/internal/", "/static/", "/inbox") "/internal/", "/static/", "/inbox")

View File

@@ -33,6 +33,14 @@ class Settings:
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 30 # 30 days 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): def __post_init__(self):
# Parse L1 servers # Parse L1 servers
l1_str = os.environ.get("L1_SERVERS", "https://celery-artdag.rose-ash.com") 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 import secrets
from datetime import datetime, timezone import time
from fastapi import APIRouter, Request, Form, HTTPException, Depends import httpx
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi import APIRouter, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.responses import RedirectResponse
from itsdangerous import URLSafeSerializer
from artdag_common import render from artdag_common.middleware.auth import UserContext, set_auth_cookie, clear_auth_cookie
from artdag_common.middleware import wants_html
from ..config import settings from ..config import settings
from ..dependencies import get_templates, get_user_from_cookie
router = APIRouter() router = APIRouter()
security = HTTPBearer(auto_error=False)
_signer = None
@router.get("/login", response_class=HTMLResponse) def _get_signer() -> URLSafeSerializer:
async def login_page(request: Request, return_to: str = None): global _signer
"""Login page.""" if _signer is None:
username = get_user_from_cookie(request) _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) @router.get("/login")
return render(templates, "auth/login.html", request, async def login(request: Request):
return_to=return_to, """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}"
response = RedirectResponse(url=authorize_url, status_code=302)
@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.set_cookie( response.set_cookie(
key="auth_token", key="oauth_state",
value=token.access_token, value=state_payload,
max_age=600, # 10 minutes
httponly=True, httponly=True,
max_age=60 * 60 * 24 * 30,
samesite="lax", samesite="lax",
secure=True, secure=True,
) )
return response return response
@router.get("/register", response_class=HTMLResponse) @router.get("/callback")
async def register_page(request: Request): async def callback(request: Request):
"""Registration page.""" """Validate state, exchange code via token endpoint, set session cookie."""
username = get_user_from_cookie(request) 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: # Adopt account's device ID as our own (one identity across all apps)
templates = get_templates(request) if account_did:
return render(templates, "auth/already_logged_in.html", request, request.state.device_id = account_did
user={"username": username}, request.state._new_device_id = True # device_id middleware will set cookie
)
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>')
# Recover state from signed cookie
state_cookie = request.cookies.get("oauth_state", "")
signer = _get_signer()
try: try:
user = await create_user(settings.data_dir, username.strip(), password, email) payload = signer.loads(state_cookie) if state_cookie else {}
except ValueError as e: except Exception:
return HTMLResponse(f'<div class="text-red-400">{str(e)}</div>') payload = {}
token = create_access_token(user.username, l2_server=f"https://{settings.domain}") next_url = payload.get("next", "/")
response = HTMLResponse(''' # Handle prompt=none rejection (user not logged in on account)
<div class="text-green-400">Registration successful! Redirecting...</div> if error == "login_required":
<script>window.location.href = "/";</script> response = RedirectResponse(url=next_url, status_code=302)
''') response.delete_cookie("oauth_state")
response.set_cookie( # Set cooldown cookie — don't re-check for 5 minutes
key="auth_token", response.set_cookie(
value=token.access_token, key="pnone_at",
httponly=True, value=str(time.time()),
max_age=60 * 60 * 24 * 30, max_age=300,
samesite="lax", httponly=True,
secure=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 return response
@router.get("/logout") @router.get("/logout")
async def logout(request: Request): async def logout():
"""Handle logout.""" """Clear session cookie, redirect through account SSO logout."""
import db response = RedirectResponse(url=settings.oauth_logout_url, status_code=302)
import requests clear_auth_cookie(response)
from auth import get_token_claims response.delete_cookie("oauth_state")
response.delete_cookie("pnone_at")
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")
return response 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_BLOG=http://blog:8000
- INTERNAL_URL_CART=http://cart:8000 - INTERNAL_URL_CART=http://cart:8000
- INTERNAL_URL_ACCOUNT=http://account: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: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8200/')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8200/')"]
interval: 10s interval: 10s

View File

@@ -7,6 +7,7 @@ bcrypt>=4.0.0
python-jose[cryptography]>=3.3.0 python-jose[cryptography]>=3.3.0
markdown>=3.5.0 markdown>=3.5.0
python-multipart>=0.0.6 python-multipart>=0.0.6
itsdangerous>=2.1.0
asyncpg>=0.29.0 asyncpg>=0.29.0
boto3>=1.34.0 boto3>=1.34.0
# common (artdag_common) installed from local dir in Dockerfile # common (artdag_common) installed from local dir in Dockerfile