Replace L2 JWT auth with OAuth SSO via account.rose-ash.com

- config.py: OAuth settings replace l2_server/l2_domain
- auth.py: full rewrite — login/callback/logout with itsdangerous
  signed state cookies and httpx token exchange
- dependencies.py: remove l2_server assignment, fix redirect path
- home.py: simplify /login to redirect to /auth/login
- base.html: cross-app nav (Blog, Market, Account) + Rose Ash branding
- requirements.txt: add itsdangerous

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-23 23:26:17 +00:00
parent ca4e86d07e
commit 49097eef53
6 changed files with 136 additions and 133 deletions

View File

@@ -44,12 +44,24 @@ class Settings:
default_factory=lambda: os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.io/ipfs")
)
# L2 Server
l2_server: Optional[str] = field(
default_factory=lambda: os.environ.get("L2_SERVER")
# OAuth SSO (replaces L2 auth)
oauth_authorize_url: str = field(
default_factory=lambda: os.environ.get("OAUTH_AUTHORIZE_URL", "https://account.rose-ash.com/auth/oauth/authorize")
)
l2_domain: Optional[str] = field(
default_factory=lambda: os.environ.get("L2_DOMAIN")
oauth_token_url: str = field(
default_factory=lambda: os.environ.get("OAUTH_TOKEN_URL", "https://account.rose-ash.com/auth/oauth/token")
)
oauth_client_id: str = field(
default_factory=lambda: os.environ.get("OAUTH_CLIENT_ID", "artdag")
)
oauth_redirect_uri: str = field(
default_factory=lambda: os.environ.get("OAUTH_REDIRECT_URI", "https://celery-artdag.rose-ash.com/auth/callback")
)
oauth_logout_url: str = field(
default_factory=lambda: os.environ.get("OAUTH_LOGOUT_URL", "https://account.rose-ash.com/auth/sso-logout/")
)
secret_key: str = field(
default_factory=lambda: os.environ.get("SECRET_KEY", "change-me-in-production")
)
# GPU/Streaming settings
@@ -91,7 +103,8 @@ class Settings:
output(f" ipfs_gateway_url: {self.ipfs_gateway_url}")
output(f" ipfs_gateways: {self.ipfs_gateways[:50]}...")
output(f" streaming_gpu_persist: {self.streaming_gpu_persist}")
output(f" l2_server: {self.l2_server}")
output(f" oauth_client_id: {self.oauth_client_id}")
output(f" oauth_authorize_url: {self.oauth_authorize_url}")
output("=" * 60)

View File

@@ -64,15 +64,10 @@ async def get_current_user(request: Request) -> Optional[UserContext]:
# Try header first (API clients)
ctx = get_user_from_header(request)
if ctx:
# Add l2_server from settings
ctx.l2_server = settings.l2_server
return ctx
# Fall back to cookie (browser)
ctx = get_user_from_cookie(request)
if ctx:
ctx.l2_server = settings.l2_server
return ctx
return get_user_from_cookie(request)
async def require_auth(request: Request) -> UserContext:
@@ -90,7 +85,7 @@ async def require_auth(request: Request) -> UserContext:
if "text/html" in accept:
raise HTTPException(
status_code=302,
headers={"Location": "/login"}
headers={"Location": "/auth/login"}
)
raise HTTPException(status_code=401, detail="Authentication required")
return ctx

View File

@@ -1,122 +1,123 @@
"""
Authentication routes for L1 server.
Authentication routes — OAuth2 authorization code flow via account.rose-ash.com.
L1 doesn't handle login directly - users log in at their L2 server.
Token is passed via URL from L2 redirect, then L1 sets its own cookie.
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
"""
from fastapi import APIRouter, Depends, HTTPException, Request
import secrets
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from itsdangerous import URLSafeSerializer
from ..dependencies import get_redis_client
from ..services.auth_service import AuthService
from artdag_common.middleware.auth import UserContext, set_auth_cookie, clear_auth_cookie
from ..config import settings
router = APIRouter()
security = HTTPBearer(auto_error=False)
_signer = None
def get_auth_service():
"""Get auth service instance."""
return AuthService(get_redis_client())
def _get_signer() -> URLSafeSerializer:
global _signer
if _signer is None:
_signer = URLSafeSerializer(settings.secret_key, salt="oauth-state")
return _signer
class RevokeUserRequest(BaseModel):
"""Request to revoke all tokens for a user."""
username: str
l2_server: str
@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", "/")
state = secrets.token_urlsafe(32)
signer = _get_signer()
state_payload = signer.dumps({"state": state, "next": next_url})
@router.get("")
async def auth_callback(
request: Request,
auth_token: str = None,
auth_service: AuthService = Depends(get_auth_service),
):
"""
Receive auth token from L2 redirect and set local cookie.
This enables cross-subdomain auth on iOS Safari which blocks shared cookies.
L2 redirects here with ?auth_token=... after user logs in.
"""
if not auth_token:
return RedirectResponse(url="/", status_code=302)
# Verify the token is valid
ctx = await auth_service.verify_token_with_l2(auth_token)
if not ctx:
return RedirectResponse(url="/", status_code=302)
# Register token for this user (for revocation by username later)
auth_service.register_user_token(ctx.username, auth_token)
# Set local first-party cookie and redirect to runs
response = RedirectResponse(url="/runs", status_code=302)
response.set_cookie(
key="auth_token",
value=auth_token,
httponly=True,
max_age=60 * 60 * 24 * 30, # 30 days
samesite="lax",
secure=True
authorize_url = (
f"{settings.oauth_authorize_url}"
f"?client_id={settings.oauth_client_id}"
f"&redirect_uri={settings.oauth_redirect_uri}"
f"&state={state}"
)
response = RedirectResponse(url=authorize_url, status_code=302)
response.set_cookie(
key="oauth_state",
value=state_payload,
max_age=600, # 10 minutes
httponly=True,
samesite="lax",
secure=True,
)
return response
@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", "")
# Recover and validate state from signed cookie
state_cookie = request.cookies.get("oauth_state", "")
if not state_cookie or not code or not state:
return RedirectResponse(url="/", status_code=302)
signer = _get_signer()
try:
payload = signer.loads(state_cookie)
except Exception:
return RedirectResponse(url="/", status_code=302)
if payload.get("state") != state:
return RedirectResponse(url="/", status_code=302)
next_url = payload.get("next", "/")
# 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
display_name = data.get("display_name", "")
username = data.get("username", "")
actor_id = f"@{display_name}" if display_name else f"@{username}"
user = UserContext(username=username, actor_id=actor_id)
response = RedirectResponse(url=next_url, status_code=302)
set_auth_cookie(response, user)
# Clear the temporary state cookie
response.delete_cookie("oauth_state")
return response
@router.get("/logout")
async def logout():
"""
Logout - clear local cookie and redirect to home.
Note: This only logs out of L1. User should also logout from L2.
"""
response = RedirectResponse(url="/", status_code=302)
response.delete_cookie("auth_token")
"""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")
return response
@router.post("/revoke")
async def revoke_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
auth_service: AuthService = Depends(get_auth_service),
):
"""
Revoke a token. Called by L2 when user logs out.
The token to revoke is passed in the Authorization header.
"""
if not credentials:
raise HTTPException(401, "No token provided")
token = credentials.credentials
# Verify token is valid before revoking (ensures caller has the token)
ctx = auth_service.get_user_context_from_token(token)
if not ctx:
raise HTTPException(401, "Invalid token")
# Revoke the token
newly_revoked = auth_service.revoke_token(token)
return {"revoked": True, "newly_revoked": newly_revoked}
@router.post("/revoke-user")
async def revoke_user_tokens(
request: RevokeUserRequest,
auth_service: AuthService = Depends(get_auth_service),
):
"""
Revoke all tokens for a user. Called by L2 when user logs out.
This handles the case where L2 issued scoped tokens that differ from L2's own token.
"""
# Revoke all tokens registered for this user
count = auth_service.revoke_all_user_tokens(request.username)
return {
"revoked": True,
"tokens_revoked": count,
"username": request.username
}

View File

@@ -227,23 +227,8 @@ async def home(request: Request):
@router.get("/login")
async def login_redirect(request: Request):
"""
Redirect to L2 for login.
"""
from ..config import settings
if settings.l2_server:
# Redirect to L2 login with return URL
return_url = str(request.url_for("auth_callback"))
login_url = f"{settings.l2_server}/login?return_to={return_url}"
return RedirectResponse(url=login_url, status_code=302)
# No L2 configured - show error
return HTMLResponse(
"<html><body><h1>Login not configured</h1>"
"<p>No L2 server configured for authentication.</p></body></html>",
status_code=503
)
"""Redirect to OAuth login flow."""
return RedirectResponse(url="/auth/login", status_code=302)
# Client tarball path

View File

@@ -1,6 +1,10 @@
{% extends "_base.html" %}
{% block brand %}Art-DAG L1{% endblock %}
{% block brand %}
<a href="https://blog.rose-ash.com/" class="text-white hover:text-gray-200 no-underline">Rose Ash</a>
<span class="text-gray-500 mx-1">|</span>
Art-DAG
{% endblock %}
{% block nav_items %}
<nav class="flex items-center space-x-6">
@@ -10,6 +14,10 @@
<a href="/media" class="text-gray-300 hover:text-white {% if active_tab == 'media' %}text-white font-medium{% endif %}">Media{% if nav_counts and nav_counts.media %} ({{ nav_counts.media }}){% endif %}</a>
<a href="/storage" class="text-gray-300 hover:text-white {% if active_tab == 'storage' %}text-white font-medium{% endif %}">Storage{% if nav_counts and nav_counts.storage %} ({{ nav_counts.storage }}){% endif %}</a>
<a href="/download/client" class="text-gray-300 hover:text-white" title="Download CLI client">Client</a>
<span class="text-gray-600">|</span>
<a href="https://blog.rose-ash.com/" class="text-gray-400 hover:text-white text-sm">Blog</a>
<a href="https://market.rose-ash.com/" class="text-gray-400 hover:text-white text-sm">Market</a>
<a href="https://account.rose-ash.com/" class="text-gray-400 hover:text-white text-sm">Account</a>
</nav>
{% endblock %}
@@ -20,6 +28,6 @@
<a href="/auth/logout" class="text-gray-300 hover:text-white">Logout</a>
</div>
{% else %}
<a href="/login" class="text-gray-300 hover:text-white">Login</a>
<a href="/auth/login" class="text-gray-300 hover:text-white">Login</a>
{% endif %}
{% endblock %}

View File

@@ -2,6 +2,7 @@ celery[redis]>=5.3.0
redis>=5.0.0
requests>=2.31.0
httpx>=0.27.0
itsdangerous>=2.0
fastapi>=0.109.0
uvicorn>=0.27.0
python-multipart>=0.0.6