mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-10 07:38:04 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
# ================================================================
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">✓</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'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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user