feat(platform): add login redirect flow for OAuth and Connect endpoints

- Fix OAuth resume CORS issue by adding JSON response support
- Add X-Frame-Options: DENY security headers to OAuth endpoints
- Fix authentication fallthrough vulnerability in OAuth router
- Replace dangerouslySetInnerHTML with React consent components
- Add rate limiting to consent submission endpoint

Connect flow for unauthenticated users:
- Add ConnectLoginState model and Redis storage functions
- Handle unauthenticated users in /connect/{provider} endpoint
- Add /connect/resume endpoint for post-login continuation
- Create /auth/connect-resume frontend page
- Update login page to handle connect_session parameter
- Update auth callback to redirect to connect-resume

This enables the full credential broker popup flow where:
1. External app opens popup to /connect/{provider}
2. If user not logged in -> redirect to /login?connect_session=X
3. User logs in -> redirect to /auth/connect-resume
4. User sees consent form and approves
5. postMessage returns grant_id to opener

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Swifty
2025-12-12 19:06:03 +01:00
parent 7e145734c7
commit 4c851414a9
8 changed files with 1686 additions and 59 deletions

View File

@@ -16,8 +16,9 @@ import logging
from typing import Annotated, Optional
from autogpt_libs.auth import get_user_id
from autogpt_libs.auth.jwt_utils import parse_jwt_token
from fastapi import APIRouter, Form, Query, Request, Security
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from prisma.enums import CredentialGrantPermission
from backend.data.credential_grants import (
@@ -38,9 +39,11 @@ from backend.integrations.oauth import HANDLERS_BY_NAME
from backend.integrations.providers import ProviderName
from backend.server.integrations.connect_security import (
consume_connect_continuation,
consume_connect_login_state,
consume_connect_state,
create_post_message_data,
store_connect_continuation,
store_connect_login_state,
store_connect_state,
validate_nonce,
validate_redirect_origin,
@@ -432,49 +435,162 @@ def _render_result_page(
"""
def _try_get_user_id(request: Request) -> Optional[str]:
"""
Attempt to extract user ID from request without raising an error.
Checks Authorization header (Bearer token) and access_token cookie.
Returns None if no valid authentication is found.
"""
# Try Authorization header
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
try:
token = auth_header[7:]
payload = parse_jwt_token(token)
return payload.get("sub")
except Exception:
pass
# Try cookie
token = request.cookies.get("access_token")
if token:
try:
payload = parse_jwt_token(token)
return payload.get("sub")
except Exception:
pass
return None
def _render_login_redirect_page(login_url: str) -> str:
"""Render a page that redirects to login."""
safe_login_url = html.escape(login_url)
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="0;url={safe_login_url}">
<title>Login Required - AutoGPT</title>
<style>{_base_styles()}</style>
</head>
<body>
<div class="container">
<h1>Login Required</h1>
<p class="subtitle" style="margin-top: 16px;">
Redirecting to login...
</p>
<a href="{safe_login_url}" class="btn btn-primary" style="display: inline-block; text-decoration: none; margin-top: 16px;">
Click here if not redirected
</a>
</div>
</body>
</html>
"""
def _wants_json(request: Request) -> bool:
"""Check if client prefers JSON response."""
accept = request.headers.get("Accept", "")
return "application/json" in accept
def _add_security_headers(response: HTMLResponse) -> HTMLResponse:
"""Add security headers to connect HTML responses."""
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
response.headers["X-Content-Type-Options"] = "nosniff"
return response
@connect_router.get("/{provider}", response_model=None)
async def connect_page(
request: Request,
provider: ProviderName,
client_id: Annotated[str, Query(description="OAuth client ID")],
scopes: Annotated[str, Query(description="Comma-separated integration scopes")],
nonce: Annotated[str, Query(description="Nonce for replay protection")],
redirect_origin: Annotated[str, Query(description="Origin for postMessage")],
user_id: Annotated[str, Security(get_user_id)],
) -> HTMLResponse:
) -> HTMLResponse | JSONResponse:
"""
Render the connect consent page.
This page allows users to select an existing credential or connect a new one
for use by an external application.
If the user is not authenticated, stores the connect params and redirects
to login. After login, user is redirected to /auth/connect-resume to continue.
"""
# Validate client
wants_json = _wants_json(request)
# Validate client first (before checking auth)
client = await prisma.oauthclient.find_unique(where={"clientId": client_id})
if not client:
return HTMLResponse(
_render_error_page("invalid_client", "Unknown application"),
status_code=400,
if wants_json:
return JSONResponse(
{"error": "invalid_client", "error_description": "Unknown application"},
status_code=400,
)
return _add_security_headers(
HTMLResponse(
_render_error_page("invalid_client", "Unknown application"),
status_code=400,
)
)
if client.status.value != "ACTIVE":
return HTMLResponse(
_render_error_page("invalid_client", "Application is not active"),
status_code=400,
if wants_json:
return JSONResponse(
{
"error": "invalid_client",
"error_description": "Application is not active",
},
status_code=400,
)
return _add_security_headers(
HTMLResponse(
_render_error_page("invalid_client", "Application is not active"),
status_code=400,
)
)
# Validate redirect origin
if not validate_redirect_origin(redirect_origin, client):
return HTMLResponse(
_render_error_page(
"invalid_request", "Invalid redirect origin for this application"
),
status_code=400,
if wants_json:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Invalid redirect origin for this application",
},
status_code=400,
)
return _add_security_headers(
HTMLResponse(
_render_error_page(
"invalid_request", "Invalid redirect origin for this application"
),
status_code=400,
)
)
# Validate nonce
if not await validate_nonce(client_id, nonce):
return HTMLResponse(
_render_error_page("invalid_request", "Nonce has already been used"),
status_code=400,
if wants_json:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Nonce has already been used",
},
status_code=400,
)
return _add_security_headers(
HTMLResponse(
_render_error_page("invalid_request", "Nonce has already been used"),
status_code=400,
)
)
# Parse and validate scopes
@@ -484,11 +600,21 @@ async def connect_page(
if not valid:
# HTML escape user input to prevent XSS
escaped_invalid = html.escape(", ".join(invalid))
return HTMLResponse(
_render_error_page(
"invalid_scope", f"Invalid scopes requested: {escaped_invalid}"
),
status_code=400,
if wants_json:
return JSONResponse(
{
"error": "invalid_scope",
"error_description": f"Invalid scopes requested: {escaped_invalid}",
},
status_code=400,
)
return _add_security_headers(
HTMLResponse(
_render_error_page(
"invalid_scope", f"Invalid scopes requested: {escaped_invalid}"
),
status_code=400,
)
)
# Verify all scopes are for the requested provider
@@ -497,14 +623,77 @@ async def connect_page(
if scope_provider != provider:
# HTML escape user input to prevent XSS
escaped_scope = html.escape(scope)
return HTMLResponse(
_render_error_page(
"invalid_scope",
f"Scope '{escaped_scope}' is not for provider '{provider.value}'",
),
status_code=400,
if wants_json:
return JSONResponse(
{
"error": "invalid_scope",
"error_description": f"Scope '{escaped_scope}' is not for provider '{provider.value}'",
},
status_code=400,
)
return _add_security_headers(
HTMLResponse(
_render_error_page(
"invalid_scope",
f"Scope '{escaped_scope}' is not for provider '{provider.value}'",
),
status_code=400,
)
)
# Try to get user ID (optional - handles unauthenticated users)
user_id = _try_get_user_id(request)
if not user_id:
# User needs to log in - store connect params and redirect to frontend login
frontend_base_url = settings.config.frontend_base_url
if not frontend_base_url:
if wants_json:
return JSONResponse(
{
"error": "server_error",
"error_description": "Frontend URL not configured",
},
status_code=500,
)
return _add_security_headers(
HTMLResponse(
_render_error_page("server_error", "Frontend URL not configured"),
status_code=500,
)
)
# Store login state
login_token = await store_connect_login_state(
client_id=client_id,
provider=provider.value,
requested_scopes=requested_scopes,
redirect_origin=redirect_origin,
nonce=nonce,
)
# Redirect to frontend login with connect_session parameter
login_url = f"{frontend_base_url}/login?connect_session={login_token}"
logger.info(
f"Redirecting unauthenticated user to login for connect flow: "
f"provider={provider.value}, client={client_id}"
)
if wants_json:
return JSONResponse(
{
"error": "login_required",
"error_description": "Authentication required",
"redirect_url": login_url,
},
status_code=401,
)
return _add_security_headers(
HTMLResponse(_render_login_redirect_page(login_url))
)
# User is authenticated - continue with normal flow
# Get user's existing credentials for this provider
user_credentials = await creds_manager.store.get_creds_by_provider(
user_id, provider
@@ -523,14 +712,16 @@ async def connect_page(
nonce=nonce,
)
return HTMLResponse(
_render_connect_page(
client_name=client.name,
provider=provider.value,
scopes=requested_scopes,
credentials=oauth_credentials,
connect_token=connect_token,
action_url=f"/connect/{provider.value}/approve",
return _add_security_headers(
HTMLResponse(
_render_connect_page(
client_name=client.name,
provider=provider.value,
scopes=requested_scopes,
credentials=oauth_credentials,
connect_token=connect_token,
action_url=f"/connect/{provider.value}/approve",
)
)
)
@@ -848,6 +1039,151 @@ async def connect_oauth_callback(
return HTMLResponse(_render_result_page(False, redirect_origin, post_data))
@connect_router.get("/resume", response_model=None)
async def resume_connect(
request: Request,
session_id: Annotated[str, Query(description="Connect login session ID")],
user_id: Annotated[str, Security(get_user_id)],
) -> HTMLResponse | JSONResponse:
"""
Resume connect flow after user login.
This endpoint is called after the user completes login on the frontend.
It retrieves the stored connect parameters and continues the connect flow.
Supports Accept: application/json header to return JSON for frontend fetch calls.
"""
wants_json = _wants_json(request)
# Retrieve and delete login state (one-time use)
login_state = await consume_connect_login_state(session_id)
if not login_state:
logger.warning(f"Connect login state not found for session_id: {session_id}")
if wants_json:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Invalid or expired connect session",
},
status_code=400,
)
return _add_security_headers(
HTMLResponse(
_render_error_page(
"invalid_request",
"Invalid or expired connect session. Please start over.",
),
status_code=400,
)
)
# Extract stored parameters
client_id = login_state.client_id
provider = login_state.provider
requested_scopes = login_state.requested_scopes
redirect_origin = login_state.redirect_origin
nonce = login_state.nonce
# Re-validate client
client = await prisma.oauthclient.find_unique(where={"clientId": client_id})
if not client or client.status.value != "ACTIVE":
if wants_json:
return JSONResponse(
{
"error": "invalid_client",
"error_description": "Client not found or inactive",
},
status_code=400,
)
return _add_security_headers(
HTMLResponse(
_render_error_page("invalid_client", "Client not found or inactive"),
status_code=400,
)
)
# Convert provider string back to ProviderName
try:
provider_enum = ProviderName(provider)
except ValueError:
if wants_json:
return JSONResponse(
{
"error": "invalid_request",
"error_description": f"Invalid provider: {provider}",
},
status_code=400,
)
return _add_security_headers(
HTMLResponse(
_render_error_page("invalid_request", f"Invalid provider: {provider}"),
status_code=400,
)
)
# Get user's existing credentials for this provider
user_credentials = await creds_manager.store.get_creds_by_provider(
user_id, provider_enum
)
oauth_credentials = [
c for c in user_credentials if isinstance(c, OAuth2Credentials)
]
# Store connect state for the consent form
connect_token = await store_connect_state(
user_id=user_id,
client_id=client_id,
provider=provider,
requested_scopes=requested_scopes,
redirect_origin=redirect_origin,
nonce=nonce,
)
# For JSON requests, return connect data instead of HTML
if wants_json:
from backend.data.integration_scopes import INTEGRATION_SCOPE_DESCRIPTIONS
scope_details = [
{"scope": s, "description": INTEGRATION_SCOPE_DESCRIPTIONS.get(s, s)}
for s in requested_scopes
]
credentials_list = [
{
"id": c.id,
"title": c.title or c.username or "Credential",
"username": c.username or "",
}
for c in oauth_credentials
]
return JSONResponse(
{
"connect_token": connect_token,
"client": {
"name": client.name,
"logo_url": getattr(client, "logoUrl", None),
},
"provider": provider,
"scopes": scope_details,
"credentials": credentials_list,
"action_url": f"/connect/{provider}/approve",
}
)
# Render consent page (HTML response)
return _add_security_headers(
HTMLResponse(
_render_connect_page(
client_name=client.name,
provider=provider,
scopes=requested_scopes,
credentials=oauth_credentials,
connect_token=connect_token,
action_url=f"/connect/{provider}/approve",
)
)
)
def _get_provider_oauth_handler(request: Request, provider_name: ProviderName):
"""Get the OAuth handler for a provider."""
from typing import TYPE_CHECKING

View File

@@ -22,6 +22,7 @@ logger = logging.getLogger(__name__)
# State expiration time
STATE_EXPIRATION_SECONDS = 600 # 10 minutes
NONCE_EXPIRATION_SECONDS = 3600 # 1 hour (nonces valid for longer to prevent races)
LOGIN_STATE_EXPIRATION_SECONDS = 600 # 10 minutes for login redirect flow
class ConnectState(BaseModel):
@@ -57,6 +58,24 @@ class ConnectContinuationState(BaseModel):
created_at: str
class ConnectLoginState(BaseModel):
"""
State for connect flow when user needs to log in first.
When an unauthenticated user tries to access /connect/{provider},
we store the connect parameters and redirect to login. After login,
the user is redirected back to complete the connect flow.
"""
client_id: str
provider: str
requested_scopes: list[str]
redirect_origin: str
nonce: str
created_at: str
expires_at: str
# Continuation state expiration (same as regular state)
CONTINUATION_EXPIRATION_SECONDS = 600 # 10 minutes
@@ -254,6 +273,97 @@ async def consume_connect_state(token: str) -> Optional[ConnectState]:
return state
async def store_connect_login_state(
client_id: str,
provider: str,
requested_scopes: list[str],
redirect_origin: str,
nonce: str,
) -> str:
"""
Store connect parameters for unauthenticated users.
When a user isn't logged in, we store the connect params and redirect
to login. After login, the frontend calls /connect/resume with the token.
Args:
client_id: OAuth client ID
provider: Integration provider name
requested_scopes: Requested integration scopes
redirect_origin: Origin to send postMessage to
nonce: Client-provided nonce for replay protection
Returns:
Login state token to be used after login completes
"""
token = generate_connect_token()
now = datetime.now(timezone.utc)
expires_at = now.timestamp() + LOGIN_STATE_EXPIRATION_SECONDS
state = ConnectLoginState(
client_id=client_id,
provider=provider,
requested_scopes=requested_scopes,
redirect_origin=redirect_origin,
nonce=nonce,
created_at=now.isoformat(),
expires_at=datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
)
redis = await get_redis_async()
key = f"connect_login_state:{token}"
await redis.setex(key, LOGIN_STATE_EXPIRATION_SECONDS, state.model_dump_json())
logger.debug(f"Stored connect login state for token {token[:8]}...")
return token
async def get_connect_login_state(token: str) -> Optional[ConnectLoginState]:
"""
Get connect login state without consuming it.
Args:
token: Login state token
Returns:
ConnectLoginState or None if not found/expired
"""
redis = await get_redis_async()
key = f"connect_login_state:{token}"
data = await redis.get(key)
if not data:
return None
return ConnectLoginState.model_validate_json(data)
async def consume_connect_login_state(token: str) -> Optional[ConnectLoginState]:
"""
Get and consume (delete) connect login state.
This ensures the token can only be used once.
Args:
token: Login state token
Returns:
ConnectLoginState or None if not found/expired
"""
redis = await get_redis_async()
key = f"connect_login_state:{token}"
# Atomic get-and-delete to prevent race conditions
data = await redis.getdel(key)
if not data:
return None
state = ConnectLoginState.model_validate_json(data)
logger.debug(f"Consumed connect login state for token {token[:8]}...")
return state
async def validate_nonce(client_id: str, nonce: str) -> bool:
"""
Validate that a nonce hasn't been used before (replay protection).

View File

@@ -29,11 +29,11 @@ from backend.data.redis_client import get_redis_async
from backend.server.oauth.consent_templates import (
render_consent_page,
render_error_page,
render_login_redirect_page,
)
from backend.server.oauth.errors import (
InvalidClientError,
InvalidRequestError,
LoginRequiredError,
OAuthError,
UnsupportedGrantTypeError,
)
@@ -50,6 +50,31 @@ oauth_router = APIRouter(prefix="/oauth", tags=["oauth"])
CONSENT_STATE_PREFIX = "oauth:consent:"
CONSENT_STATE_TTL = 600 # 10 minutes
# Redis key prefix and TTL for login redirect state storage
LOGIN_STATE_PREFIX = "oauth:login:"
LOGIN_STATE_TTL = 900 # 15 minutes (longer to allow time for login)
async def _store_login_state(token: str, state: dict) -> None:
"""Store OAuth login state in Redis with TTL."""
redis = await get_redis_async()
await redis.setex(
f"{LOGIN_STATE_PREFIX}{token}",
LOGIN_STATE_TTL,
json.dumps(state, default=str),
)
async def _get_and_delete_login_state(token: str) -> Optional[dict]:
"""Retrieve and delete login state from Redis (one-time use, atomic)."""
redis = await get_redis_async()
key = f"{LOGIN_STATE_PREFIX}{token}"
# Use GETDEL for atomic get+delete to prevent race conditions
state_json = await redis.getdel(key)
if state_json:
return json.loads(state_json)
return None
async def _store_consent_state(token: str, state: dict) -> None:
"""Store consent state in Redis with TTL."""
@@ -65,14 +90,16 @@ async def _get_and_delete_consent_state(token: str) -> Optional[dict]:
"""Retrieve and delete consent state from Redis (atomic get+delete)."""
redis = await get_redis_async()
key = f"{CONSENT_STATE_PREFIX}{token}"
state_json = await redis.get(key)
# Use GETDEL for atomic get+delete to prevent race conditions
state_json = await redis.getdel(key)
if state_json:
await redis.delete(key)
return json.loads(state_json)
return None
async def _get_user_id_from_request(request: Request) -> Optional[str]:
async def _get_user_id_from_request(
request: Request, strict_bearer: bool = False
) -> Optional[str]:
"""
Extract user ID from request, checking API key, Authorization header, and cookie.
@@ -80,6 +107,11 @@ async def _get_user_id_from_request(request: Request) -> Optional[str]:
1. X-API-Key header - API key authentication (preferred for external apps)
2. Authorization: Bearer <jwt> - JWT token authentication
3. access_token cookie - Cookie-based auth (for browser flows)
Args:
request: The incoming request
strict_bearer: If True and Bearer token is provided but invalid,
do NOT fallthrough to cookie auth (prevents auth downgrade attacks)
"""
from autogpt_libs.auth.jwt_utils import parse_jwt_token
@@ -104,6 +136,10 @@ async def _get_user_id_from_request(request: Request) -> Optional[str]:
return payload.get("sub")
except Exception as e:
logger.debug("JWT token validation failed: %s", type(e).__name__)
# Security fix: If Bearer token was provided but invalid,
# don't fallthrough to weaker auth methods when strict_bearer is True
if strict_bearer:
return None
# Finally try cookie (browser-based auth)
token = request.cookies.get("access_token")
@@ -201,8 +237,49 @@ async def authorize(
# Check if user is authenticated
if not user_id:
# Authentication required - user must provide API key or JWT
raise LoginRequiredError(state=state)
# User needs to log in - store OAuth params and redirect to frontend login
from backend.util.settings import Settings
settings = Settings()
login_token = secrets.token_urlsafe(32)
logger.info(f"Storing login state with token: {login_token}")
await _store_login_state(
login_token,
{
"client_id": client_id,
"redirect_uri": redirect_uri,
"scopes": scopes,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"nonce": nonce,
"prompt": prompt,
"created_at": datetime.now(timezone.utc).isoformat(),
"expires_at": (
datetime.now(timezone.utc) + timedelta(seconds=LOGIN_STATE_TTL)
).isoformat(),
},
)
logger.info(f"Login state stored successfully for token: {login_token}")
# Build redirect URL to frontend login
frontend_base_url = settings.config.frontend_base_url
if not frontend_base_url:
return _add_security_headers(
HTMLResponse(
render_error_page(
"server_error", "Frontend URL not configured"
),
status_code=500,
)
)
# Redirect to frontend login with oauth_session parameter
login_url = f"{frontend_base_url}/login?oauth_session={login_token}"
return _add_security_headers(
HTMLResponse(render_login_redirect_page(login_url))
)
# Check if user has already authorized these scopes
if prompt != "consent":
@@ -245,15 +322,17 @@ async def authorize(
)
# Render consent page
return HTMLResponse(
render_consent_page(
client_name=client.name,
client_logo=client.logoUrl,
scopes=scopes,
consent_token=consent_token,
action_url="/oauth/authorize/consent",
privacy_policy_url=client.privacyPolicyUrl,
terms_url=client.termsOfServiceUrl,
return _add_security_headers(
HTMLResponse(
render_consent_page(
client_name=client.name,
client_logo=client.logoUrl,
scopes=scopes,
consent_token=consent_token,
action_url="/oauth/authorize/consent",
privacy_policy_url=client.privacyPolicyUrl,
terms_url=client.termsOfServiceUrl,
)
)
)
@@ -267,9 +346,11 @@ async def authorize(
except Exception:
pass
return HTMLResponse(
render_error_page(e.error.value, e.description or "An error occurred"),
status_code=400,
return _add_security_headers(
HTMLResponse(
render_error_page(e.error.value, e.description or "An error occurred"),
status_code=400,
)
)
@@ -284,6 +365,20 @@ async def submit_consent(
Creates authorization code and redirects to client's redirect_uri.
"""
# Rate limiting on consent submission to prevent brute force attacks
client_ip = _get_client_ip(request)
rate_result = await check_rate_limit(client_ip, "oauth_consent")
if not rate_result.allowed:
return _add_security_headers(
HTMLResponse(
render_error_page(
"rate_limit_exceeded",
"Too many consent requests. Please try again later.",
),
status_code=429,
)
)
oauth_service = get_oauth_service()
# Validate consent token (retrieves and deletes from Redis atomically)
@@ -341,6 +436,249 @@ async def submit_consent(
return e.to_redirect(redirect_uri)
def _wants_json(request: Request) -> bool:
"""Check if client prefers JSON response (for frontend fetch calls)."""
accept = request.headers.get("Accept", "")
return "application/json" in accept
def _add_security_headers(response: HTMLResponse) -> HTMLResponse:
"""Add security headers to OAuth HTML responses."""
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
response.headers["X-Content-Type-Options"] = "nosniff"
return response
@oauth_router.get("/authorize/resume", response_model=None)
async def resume_authorization(
request: Request,
session_id: str = Query(..., description="OAuth login session ID"),
) -> HTMLResponse | RedirectResponse | JSONResponse:
"""
Resume OAuth authorization after user login.
This endpoint is called after the user completes login on the frontend.
It retrieves the stored OAuth parameters and continues the authorization flow.
Supports Accept: application/json header to return JSON for frontend fetch calls,
solving CORS issues with redirect responses.
"""
wants_json = _wants_json(request)
# Rate limiting - use client IP
client_ip = _get_client_ip(request)
rate_result = await check_rate_limit(client_ip, "oauth_authorize")
if not rate_result.allowed:
if wants_json:
return JSONResponse(
{
"error": "rate_limit_exceeded",
"error_description": "Too many requests",
},
status_code=429,
)
return _add_security_headers(
HTMLResponse(
render_error_page(
"rate_limit_exceeded",
"Too many authorization requests. Please try again later.",
),
status_code=429,
)
)
# Verify user is now authenticated (use strict_bearer to prevent auth downgrade)
user_id = await _get_user_id_from_request(request, strict_bearer=True)
if not user_id:
from backend.util.settings import Settings
frontend_url = Settings().config.frontend_base_url or "http://localhost:3000"
if wants_json:
return JSONResponse(
{
"error": "login_required",
"error_description": "Authentication required",
"redirect_url": f"{frontend_url}/login",
},
status_code=401,
)
return _add_security_headers(
HTMLResponse(
render_error_page(
"login_required",
"Authentication required. Please log in and try again.",
redirect_url=f"{frontend_url}/login",
),
status_code=401,
)
)
# Retrieve and delete login state (one-time use)
logger.info(f"Attempting to retrieve login state for session_id: {session_id}")
login_state = await _get_and_delete_login_state(session_id)
if not login_state:
logger.warning(f"Login state not found for session_id: {session_id}")
if wants_json:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Invalid or expired authorization session",
},
status_code=400,
)
return _add_security_headers(
HTMLResponse(
render_error_page(
"invalid_request",
"Invalid or expired authorization session. Please start over.",
),
status_code=400,
)
)
# Check expiration
expires_at = datetime.fromisoformat(login_state["expires_at"])
if expires_at < datetime.now(timezone.utc):
if wants_json:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Authorization session has expired",
},
status_code=400,
)
return _add_security_headers(
HTMLResponse(
render_error_page(
"invalid_request",
"Authorization session has expired. Please start over.",
),
status_code=400,
)
)
# Extract stored OAuth parameters
client_id = login_state["client_id"]
redirect_uri = login_state["redirect_uri"]
scopes = login_state["scopes"]
state = login_state["state"]
code_challenge = login_state["code_challenge"]
code_challenge_method = login_state["code_challenge_method"]
nonce = login_state.get("nonce")
prompt = login_state.get("prompt")
oauth_service = get_oauth_service()
try:
# Re-validate client (in case it was deactivated during login)
client = await oauth_service.validate_client(client_id, redirect_uri, scopes)
# Check if user has already authorized these scopes (skip consent if yes)
if prompt != "consent":
has_auth = await oauth_service.has_valid_authorization(
user_id, client_id, scopes
)
if has_auth:
# Skip consent, issue code directly
code = await oauth_service.create_authorization_code(
user_id=user_id,
client_id=client_id,
redirect_uri=redirect_uri,
scopes=scopes,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
nonce=nonce,
)
redirect_url = (
f"{redirect_uri}?{urlencode({'code': code, 'state': state})}"
)
# Return JSON with redirect URL for frontend to handle
if wants_json:
return JSONResponse(
{"redirect_url": redirect_url, "needs_consent": False}
)
return RedirectResponse(url=redirect_url, status_code=302)
# Generate consent token and store state in Redis
consent_token = secrets.token_urlsafe(32)
await _store_consent_state(
consent_token,
{
"user_id": user_id,
"client_id": client_id,
"redirect_uri": redirect_uri,
"scopes": scopes,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"nonce": nonce,
"expires_at": (
datetime.now(timezone.utc) + timedelta(minutes=10)
).isoformat(),
},
)
# For JSON requests, return consent data instead of HTML
if wants_json:
from backend.server.oauth.models import SCOPE_DESCRIPTIONS
scope_details = [
{"scope": s, "description": SCOPE_DESCRIPTIONS.get(s, s)}
for s in scopes
]
return JSONResponse(
{
"needs_consent": True,
"consent_token": consent_token,
"client": {
"name": client.name,
"logo_url": client.logoUrl,
"privacy_policy_url": client.privacyPolicyUrl,
"terms_url": client.termsOfServiceUrl,
},
"scopes": scope_details,
"action_url": "/oauth/authorize/consent",
}
)
# Render consent page (HTML response)
return _add_security_headers(
HTMLResponse(
render_consent_page(
client_name=client.name,
client_logo=client.logoUrl,
scopes=scopes,
consent_token=consent_token,
action_url="/oauth/authorize/consent",
privacy_policy_url=client.privacyPolicyUrl,
terms_url=client.termsOfServiceUrl,
)
)
)
except OAuthError as e:
if wants_json:
return JSONResponse(
{"error": e.error.value, "error_description": e.description},
status_code=400,
)
# If we have a valid redirect_uri, redirect with error
try:
client = await oauth_service.get_client(client_id)
if client and redirect_uri in client.redirectUris:
return e.to_redirect(redirect_uri)
except Exception:
pass
return _add_security_headers(
HTMLResponse(
render_error_page(e.error.value, e.description or "An error occurred"),
status_code=400,
)
)
# ================================================================
# Token Endpoint
# ================================================================

View File

@@ -176,6 +176,7 @@ DEFAULT_RATE_LIMITS = {
# OAuth endpoints
"oauth_authorize": {"minute": (30, 60)}, # 30/min per IP
"oauth_token": {"minute": (20, 60)}, # 20/min per client
"oauth_consent": {"minute": (20, 60)}, # 20/min per IP for consent submission
# External API endpoints
"api_execute": {
"minute": (10, 60),

View File

@@ -8,6 +8,8 @@ import { shouldShowOnboarding } from "@/app/api/helpers";
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const oauthSession = searchParams.get("oauth_session");
const connectSession = searchParams.get("connect_session");
let next = "/marketplace";
@@ -25,6 +27,22 @@ export async function GET(request: Request) {
const api = new BackendAPI();
await api.createUser();
// Handle oauth_session redirect - resume OAuth flow after login
// Redirect to a frontend page that will handle the OAuth resume with proper auth
if (oauthSession) {
return NextResponse.redirect(
`${origin}/auth/oauth-resume?session_id=${encodeURIComponent(oauthSession)}`,
);
}
// Handle connect_session redirect - resume connect flow after login
// Redirect to a frontend page that will handle the connect resume with proper auth
if (connectSession) {
return NextResponse.redirect(
`${origin}/auth/connect-resume?session_id=${encodeURIComponent(connectSession)}`,
);
}
if (await shouldShowOnboarding()) {
next = "/onboarding";
revalidatePath("/onboarding", "layout");

View File

@@ -0,0 +1,400 @@
"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { getWebSocketToken } from "@/lib/supabase/actions";
// Module-level flag to prevent duplicate requests across React StrictMode re-renders
const attemptedSessions = new Set<string>();
interface ScopeInfo {
scope: string;
description: string;
}
interface CredentialInfo {
id: string;
title: string;
username: string;
}
interface ClientInfo {
name: string;
logo_url: string | null;
}
interface ConnectData {
connect_token: string;
client: ClientInfo;
provider: string;
scopes: ScopeInfo[];
credentials: CredentialInfo[];
action_url: string;
}
interface ErrorData {
error: string;
error_description: string;
}
type ResumeResponse = ConnectData | ErrorData;
function isConnectData(data: ResumeResponse): data is ConnectData {
return "connect_token" in data;
}
function isErrorData(data: ResumeResponse): data is ErrorData {
return "error" in data;
}
/**
* Connect Consent Form Component
*
* Renders a proper React component for the integration connect consent form
*/
function ConnectForm({
client,
provider,
scopes,
credentials,
connectToken,
actionUrl,
}: {
client: ClientInfo;
provider: string;
scopes: ScopeInfo[];
credentials: CredentialInfo[];
connectToken: string;
actionUrl: string;
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedCredential, setSelectedCredential] = useState<string>(
credentials.length > 0 ? credentials[0].id : "",
);
const backendUrl = process.env.NEXT_PUBLIC_AGPT_SERVER_URL;
const backendOrigin = backendUrl
? new URL(backendUrl).origin
: "http://localhost:8006";
const fullActionUrl = `${backendOrigin}${actionUrl}`;
function handleSubmit() {
setIsSubmitting(true);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-900 to-slate-800 p-5">
<div className="w-full max-w-md rounded-2xl bg-zinc-800 p-8 shadow-2xl">
{/* Header */}
<div className="mb-6 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Connect{" "}
<span className="rounded bg-zinc-700 px-2 py-1 text-sm capitalize">
{provider}
</span>
</h1>
<p className="mt-2 text-sm text-zinc-400">
<span className="font-semibold text-cyan-400">{client.name}</span>{" "}
wants to use your {provider} integration
</p>
</div>
{/* Divider */}
<div className="my-6 h-px bg-zinc-700" />
{/* Scopes Section */}
<div className="mb-6">
<h2 className="mb-4 text-sm font-medium text-zinc-400">
This will allow {client.name} to:
</h2>
<div className="space-y-2">
{scopes.map((scope) => (
<div key={scope.scope} className="flex items-start gap-2 py-2">
<span className="flex-shrink-0 text-cyan-400">&#10003;</span>
<span className="text-sm text-zinc-300">
{scope.description}
</span>
</div>
))}
</div>
</div>
{/* Divider */}
<div className="my-6 h-px bg-zinc-700" />
{/* Form */}
<form method="POST" action={fullActionUrl} onSubmit={handleSubmit}>
<input type="hidden" name="connect_token" value={connectToken} />
{/* Existing credentials selection */}
{credentials.length > 0 && (
<>
<h3 className="mb-3 text-sm font-medium text-zinc-400">
Select an existing credential:
</h3>
<div className="mb-4 space-y-2">
{credentials.map((cred) => (
<label
key={cred.id}
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors ${
selectedCredential === cred.id
? "border-cyan-400 bg-cyan-400/10"
: "border-zinc-700 hover:border-cyan-400/50"
}`}
>
<input
type="radio"
name="credential_id"
value={cred.id}
checked={selectedCredential === cred.id}
onChange={() => setSelectedCredential(cred.id)}
className="hidden"
/>
<div>
<div className="text-sm font-medium text-zinc-200">
{cred.title}
</div>
{cred.username && (
<div className="text-xs text-zinc-500">
{cred.username}
</div>
)}
</div>
</label>
))}
</div>
<div className="my-4 h-px bg-zinc-700" />
</>
)}
{/* Connect new account */}
<div className="mb-4">
{credentials.length > 0 ? (
<h3 className="mb-3 text-sm font-medium text-zinc-400">
Or connect a new account:
</h3>
) : (
<p className="mb-3 text-sm text-zinc-400">
You don&apos;t have any {provider} credentials yet.
</p>
)}
<button
type="submit"
name="action"
value="connect_new"
disabled={isSubmitting}
className="w-full rounded-lg bg-blue-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-400 disabled:cursor-not-allowed disabled:opacity-50"
>
Connect {provider.charAt(0).toUpperCase() + provider.slice(1)}{" "}
Account
</button>
</div>
{/* Action buttons */}
<div className="flex gap-3">
<button
type="submit"
name="action"
value="deny"
disabled={isSubmitting}
className="flex-1 rounded-lg bg-zinc-700 px-6 py-3 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-600 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
{credentials.length > 0 && (
<button
type="submit"
name="action"
value="approve"
disabled={isSubmitting}
className="flex-1 rounded-lg bg-cyan-400 px-6 py-3 text-sm font-medium text-slate-900 transition-colors hover:bg-cyan-300 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? "Approving..." : "Approve"}
</button>
)}
</div>
</form>
</div>
</div>
);
}
/**
* Connect Resume Page
*
* This page handles resuming the integration connect flow after a user logs in.
* It fetches the connect data from the backend via JSON API and renders the consent form.
*/
export default function ConnectResumePage() {
const searchParams = useSearchParams();
const sessionId = searchParams.get("session_id");
const { isUserLoading, refreshSession } = useSupabase();
const [connectData, setConnectData] = useState<ConnectData | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const retryCountRef = useRef(0);
const maxRetries = 5;
const resumeConnectFlow = useCallback(async () => {
if (!sessionId) {
setError(
"Missing session ID. Please start the connection process again.",
);
setIsLoading(false);
return;
}
if (attemptedSessions.has(sessionId)) {
return;
}
if (isUserLoading) {
return;
}
attemptedSessions.add(sessionId);
try {
let tokenResult = await getWebSocketToken();
let accessToken = tokenResult.token;
while (!accessToken && retryCountRef.current < maxRetries) {
retryCountRef.current += 1;
console.log(
`Retrying to get access token (attempt ${retryCountRef.current}/${maxRetries})...`,
);
await refreshSession();
await new Promise((resolve) => setTimeout(resolve, 1000));
tokenResult = await getWebSocketToken();
accessToken = tokenResult.token;
}
if (!accessToken) {
setError(
"Unable to retrieve authentication token. Please log in again.",
);
setIsLoading(false);
return;
}
const backendUrl = process.env.NEXT_PUBLIC_AGPT_SERVER_URL;
if (!backendUrl) {
setError("Backend URL not configured.");
setIsLoading(false);
return;
}
let backendOrigin: string;
try {
const url = new URL(backendUrl);
backendOrigin = url.origin;
} catch {
setError("Invalid backend URL configuration.");
setIsLoading(false);
return;
}
const response = await fetch(
`${backendOrigin}/connect/resume?session_id=${encodeURIComponent(sessionId)}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
},
);
const data: ResumeResponse = await response.json();
if (!response.ok) {
if (isErrorData(data)) {
setError(data.error_description || data.error);
} else {
setError(`Connection failed (${response.status}). Please try again.`);
}
setIsLoading(false);
return;
}
if (isConnectData(data)) {
setConnectData(data);
setIsLoading(false);
return;
}
setError("Unexpected response from server. Please try again.");
setIsLoading(false);
} catch (err) {
console.error("Connect resume error:", err);
setError(
"An error occurred while resuming connection. Please try again.",
);
setIsLoading(false);
}
}, [sessionId, isUserLoading, refreshSession]);
useEffect(() => {
resumeConnectFlow();
}, [resumeConnectFlow]);
if (isLoading || isUserLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-900 to-slate-800">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-zinc-600 border-t-cyan-400"></div>
<p className="text-zinc-400">Resuming connection...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-900 to-slate-800">
<div className="mx-auto max-w-md rounded-2xl bg-zinc-800 p-8 text-center shadow-2xl">
<div className="mx-auto mb-4 h-16 w-16 text-red-500">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</div>
<h1 className="mb-2 text-xl font-semibold text-red-400">
Connection Error
</h1>
<p className="mb-6 text-zinc-400">{error}</p>
<button
onClick={() => window.close()}
className="rounded-lg bg-zinc-700 px-6 py-3 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-600"
>
Close
</button>
</div>
</div>
);
}
if (connectData) {
return (
<ConnectForm
client={connectData.client}
provider={connectData.provider}
scopes={connectData.scopes}
credentials={connectData.credentials}
connectToken={connectData.connect_token}
actionUrl={connectData.action_url}
/>
);
}
return null;
}

View File

@@ -0,0 +1,399 @@
"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { getWebSocketToken } from "@/lib/supabase/actions";
// Module-level flag to prevent duplicate requests across React StrictMode re-renders
// This is keyed by session_id to allow different sessions
const attemptedSessions = new Set<string>();
interface ScopeInfo {
scope: string;
description: string;
}
interface ClientInfo {
name: string;
logo_url: string | null;
privacy_policy_url: string | null;
terms_url: string | null;
}
interface ConsentData {
needs_consent: true;
consent_token: string;
client: ClientInfo;
scopes: ScopeInfo[];
action_url: string;
}
interface RedirectData {
redirect_url: string;
needs_consent: false;
}
interface ErrorData {
error: string;
error_description: string;
redirect_url?: string;
}
type ResumeResponse = ConsentData | RedirectData | ErrorData;
function isConsentData(data: ResumeResponse): data is ConsentData {
return "needs_consent" in data && data.needs_consent === true;
}
function isRedirectData(data: ResumeResponse): data is RedirectData {
return "redirect_url" in data && !("error" in data);
}
function isErrorData(data: ResumeResponse): data is ErrorData {
return "error" in data;
}
/**
* OAuth Consent Form Component
*
* Renders a proper React component for the consent form instead of dangerouslySetInnerHTML
*/
function ConsentForm({
client,
scopes,
consentToken,
actionUrl,
}: {
client: ClientInfo;
scopes: ScopeInfo[];
consentToken: string;
actionUrl: string;
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
const backendUrl = process.env.NEXT_PUBLIC_AGPT_SERVER_URL;
const backendOrigin = backendUrl
? new URL(backendUrl).origin
: "http://localhost:8006";
// Full action URL for form submission
const fullActionUrl = `${backendOrigin}${actionUrl}`;
function handleSubmit() {
setIsSubmitting(true);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-900 to-slate-800 p-5">
<div className="w-full max-w-md rounded-2xl bg-zinc-800 p-8 shadow-2xl">
{/* Header */}
<div className="mb-6 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-xl bg-zinc-700">
{client.logo_url ? (
<img
src={client.logo_url}
alt={client.name}
className="h-12 w-12 rounded-lg"
/>
) : (
<span className="text-3xl text-zinc-400">
{client.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<h1 className="text-xl font-semibold text-zinc-100">
Authorize <span className="text-cyan-400">{client.name}</span>
</h1>
<p className="mt-2 text-sm text-zinc-400">
wants to access your AutoGPT account
</p>
</div>
{/* Divider */}
<div className="my-6 h-px bg-zinc-700" />
{/* Scopes Section */}
<div className="mb-6">
<h2 className="mb-4 text-sm font-medium text-zinc-400">
This will allow {client.name} to:
</h2>
<div className="space-y-3">
{scopes.map((scope) => (
<div
key={scope.scope}
className="flex items-start gap-3 border-b border-zinc-700 pb-3 last:border-0"
>
<svg
className="mt-0.5 h-5 w-5 flex-shrink-0 text-cyan-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm leading-relaxed text-zinc-300">
{scope.description}
</span>
</div>
))}
</div>
</div>
{/* Form */}
<form method="POST" action={fullActionUrl} onSubmit={handleSubmit}>
<input type="hidden" name="consent_token" value={consentToken} />
<div className="flex gap-3">
<button
type="submit"
name="authorize"
value="false"
disabled={isSubmitting}
className="flex-1 rounded-lg bg-zinc-700 px-6 py-3 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-600 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
name="authorize"
value="true"
disabled={isSubmitting}
className="flex-1 rounded-lg bg-cyan-400 px-6 py-3 text-sm font-medium text-slate-900 transition-colors hover:bg-cyan-300 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? "Authorizing..." : "Allow"}
</button>
</div>
</form>
{/* Footer Links */}
{(client.privacy_policy_url || client.terms_url) && (
<div className="mt-6 text-center text-xs text-zinc-500">
{client.privacy_policy_url && (
<a
href={client.privacy_policy_url}
target="_blank"
rel="noopener noreferrer"
className="text-zinc-400 hover:underline"
>
Privacy Policy
</a>
)}
{client.privacy_policy_url && client.terms_url && (
<span className="mx-2"></span>
)}
{client.terms_url && (
<a
href={client.terms_url}
target="_blank"
rel="noopener noreferrer"
className="text-zinc-400 hover:underline"
>
Terms of Service
</a>
)}
</div>
)}
</div>
</div>
);
}
/**
* OAuth Resume Page
*
* This page handles resuming the OAuth authorization flow after a user logs in.
* It fetches the consent data from the backend via JSON API and renders the consent form.
*/
export default function OAuthResumePage() {
const searchParams = useSearchParams();
const sessionId = searchParams.get("session_id");
const { isUserLoading, refreshSession } = useSupabase();
const [consentData, setConsentData] = useState<ConsentData | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const retryCountRef = useRef(0);
const maxRetries = 5;
const resumeOAuthFlow = useCallback(async () => {
// Prevent multiple attempts for the same session (handles React StrictMode)
if (!sessionId) {
setError(
"Missing session ID. Please start the authorization process again.",
);
setIsLoading(false);
return;
}
if (attemptedSessions.has(sessionId)) {
// Already attempted this session, don't retry
return;
}
if (isUserLoading) {
return; // Wait for auth state to load
}
// Mark this session as attempted IMMEDIATELY to prevent duplicate requests
attemptedSessions.add(sessionId);
try {
// Get the access token from server action (which reads cookies properly)
let tokenResult = await getWebSocketToken();
let accessToken = tokenResult.token;
// If no token, retry a few times with delays
while (!accessToken && retryCountRef.current < maxRetries) {
retryCountRef.current += 1;
console.log(
`Retrying to get access token (attempt ${retryCountRef.current}/${maxRetries})...`,
);
// Try refreshing the session
await refreshSession();
await new Promise((resolve) => setTimeout(resolve, 1000));
tokenResult = await getWebSocketToken();
accessToken = tokenResult.token;
}
if (!accessToken) {
setError(
"Unable to retrieve authentication token. Please log in again.",
);
setIsLoading(false);
return;
}
// Call the backend resume endpoint with JSON accept header
const backendUrl = process.env.NEXT_PUBLIC_AGPT_SERVER_URL;
if (!backendUrl) {
setError("Backend URL not configured.");
setIsLoading(false);
return;
}
// Extract the origin from the backend URL
let backendOrigin: string;
try {
const url = new URL(backendUrl);
backendOrigin = url.origin;
} catch {
setError("Invalid backend URL configuration.");
setIsLoading(false);
return;
}
// Use Accept: application/json to get JSON response instead of HTML
// This solves the CORS/redirect issue by letting us handle redirects client-side
const response = await fetch(
`${backendOrigin}/oauth/authorize/resume?session_id=${encodeURIComponent(sessionId)}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
},
);
const data: ResumeResponse = await response.json();
if (!response.ok) {
if (isErrorData(data)) {
setError(data.error_description || data.error);
} else {
setError(
`Authorization failed (${response.status}). Please try again.`,
);
}
setIsLoading(false);
return;
}
// Handle redirect response (user already authorized these scopes)
if (isRedirectData(data)) {
window.location.href = data.redirect_url;
return;
}
// Handle consent required
if (isConsentData(data)) {
setConsentData(data);
setIsLoading(false);
return;
}
// Unexpected response
setError("Unexpected response from server. Please try again.");
setIsLoading(false);
} catch (err) {
console.error("OAuth resume error:", err);
setError(
"An error occurred while resuming authorization. Please try again.",
);
setIsLoading(false);
}
}, [sessionId, isUserLoading, refreshSession]);
useEffect(() => {
resumeOAuthFlow();
}, [resumeOAuthFlow]);
if (isLoading || isUserLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-900 to-slate-800">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-zinc-600 border-t-cyan-400"></div>
<p className="text-zinc-400">Resuming authorization...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-900 to-slate-800">
<div className="mx-auto max-w-md rounded-2xl bg-zinc-800 p-8 text-center shadow-2xl">
<div className="mx-auto mb-4 h-16 w-16 text-red-500">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</div>
<h1 className="mb-2 text-xl font-semibold text-red-400">
Authorization Error
</h1>
<p className="mb-6 text-zinc-400">{error}</p>
<button
onClick={() => window.close()}
className="rounded-lg bg-zinc-700 px-6 py-3 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-600"
>
Close
</button>
</div>
</div>
);
}
if (consentData) {
return (
<ConsentForm
client={consentData.client}
scopes={consentData.scopes}
consentToken={consentData.consent_token}
actionUrl={consentData.action_url}
/>
);
}
return null;
}

View File

@@ -21,10 +21,24 @@ export function useLoginPage() {
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
const isCloudEnv = environment.isCloud();
// Get returnUrl from query params (used by OAuth flow)
// Get returnUrl, oauth_session, and connect_session from query params
const returnUrl = searchParams.get("returnUrl");
const oauthSession = searchParams.get("oauth_session");
const connectSession = searchParams.get("connect_session");
function getRedirectUrl(onboarding: boolean): string {
// OAuth session takes priority - redirect to frontend oauth-resume page
// which will handle the backend call with proper authentication
if (oauthSession) {
return `/auth/oauth-resume?session_id=${encodeURIComponent(oauthSession)}`;
}
// Connect session - redirect to frontend connect-resume page
// for integration credential connection flow
if (connectSession) {
return `/auth/connect-resume?session_id=${encodeURIComponent(connectSession)}`;
}
// If returnUrl is set and is a valid URL, redirect there after login
if (returnUrl) {
try {
@@ -56,9 +70,10 @@ export function useLoginPage() {
useEffect(() => {
if (isLoggedIn && !isLoggingIn) {
router.push(getRedirectUrl(false));
const redirectTo = getRedirectUrl(false);
router.push(redirectTo);
}
}, [isLoggedIn, isLoggingIn, returnUrl]);
}, [isLoggedIn, isLoggingIn, returnUrl, oauthSession, connectSession]);
const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
@@ -73,10 +88,20 @@ export function useLoginPage() {
setIsLoggingIn(true);
try {
// Build redirect URL that preserves oauth_session or connect_session through the OAuth flow
let callbackUrl: string | undefined;
const origin = window.location.origin;
if (oauthSession) {
callbackUrl = `${origin}/auth/callback?oauth_session=${encodeURIComponent(oauthSession)}`;
} else if (connectSession) {
callbackUrl = `${origin}/auth/callback?connect_session=${encodeURIComponent(connectSession)}`;
}
const response = await fetch("/api/auth/provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
body: JSON.stringify({ provider, redirectTo: callbackUrl }),
});
if (!response.ok) {