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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user