From 49097eef53b0510c2683593afb22e1fc2ffe0406 Mon Sep 17 00:00:00 2001 From: giles Date: Mon, 23 Feb 2026 23:26:17 +0000 Subject: [PATCH] Replace L2 JWT auth with OAuth SSO via account.rose-ash.com MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/config.py | 25 +++-- app/dependencies.py | 9 +- app/routers/auth.py | 203 ++++++++++++++++++++-------------------- app/routers/home.py | 19 +--- app/templates/base.html | 12 ++- requirements.txt | 1 + 6 files changed, 136 insertions(+), 133 deletions(-) diff --git a/app/config.py b/app/config.py index bec2032..8aa94d7 100644 --- a/app/config.py +++ b/app/config.py @@ -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) diff --git a/app/dependencies.py b/app/dependencies.py index 48fe552..fc59947 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -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 diff --git a/app/routers/auth.py b/app/routers/auth.py index 3c0da4f..84a1258 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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 - } diff --git a/app/routers/home.py b/app/routers/home.py index 356a671..8786e22 100644 --- a/app/routers/home.py +++ b/app/routers/home.py @@ -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( - "

Login not configured

" - "

No L2 server configured for authentication.

", - status_code=503 - ) + """Redirect to OAuth login flow.""" + return RedirectResponse(url="/auth/login", status_code=302) # Client tarball path diff --git a/app/templates/base.html b/app/templates/base.html index 4c44e8d..c53ceb4 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,6 +1,10 @@ {% extends "_base.html" %} -{% block brand %}Art-DAG L1{% endblock %} +{% block brand %} +Rose Ash +| +Art-DAG +{% endblock %} {% block nav_items %} {% endblock %} @@ -20,6 +28,6 @@ Logout {% else %} -Login +Login {% endif %} {% endblock %} diff --git a/requirements.txt b/requirements.txt index d991bca..ce5b1bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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