Add OAuth SSO, device ID, and silent auth to L2
- 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:
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
'<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>
|
||||
''')
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user