From d82c8f02ae07ee8713fff061043a23ebd1aba3d9 Mon Sep 17 00:00:00 2001
From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com>
Date: Thu, 3 Sep 2026 14:28:54 -0400
Subject: [PATCH 01/19] Add per-account OpenAI ChatGPT OAuth adapter
---
.../adapter_processor_v2/adapter_processor.py | 25 +-
backend/adapter_processor_v2/serializers.py | 15 +-
backend/adapter_processor_v2/views.py | 107 ++++-
backend/connector_auth_v2/openai_oauth.py | 345 ++++++++++++++
backend/connector_auth_v2/urls.py | 4 +
backend/connector_auth_v2/views.py | 46 ++
.../input-output/configure-ds/ConfigureDs.jsx | 30 +-
.../components/oauth-ds/oauth-ds/OAuthDs.jsx | 133 +++++-
.../oauth-ds/openai/OpenAIOAuthButton.css | 17 +
.../oauth-ds/openai/OpenAIOAuthButton.jsx | 56 +++
frontend/src/helpers/GetStaticData.js | 1 +
.../platform_service/controller/platform.py | 54 ++-
.../src/unstract/sdk1/adapters/adapterkit.py | 4 +
.../sdk1/src/unstract/sdk1/adapters/base1.py | 6 +
.../unstract/sdk1/adapters/llm1/__init__.py | 2 +
.../sdk1/adapters/llm1/openai_oauth.py | 106 +++++
.../adapters/llm1/static/openai-oauth.json | 93 ++++
.../sdk1/src/unstract/sdk1/auth/__init__.py | 25 +
.../src/unstract/sdk1/auth/openai_oauth.py | 236 ++++++++++
unstract/sdk1/src/unstract/sdk1/llm.py | 437 +++++++++++++++---
unstract/sdk1/tests/test_openai_oauth.py | 209 +++++++++
21 files changed, 1864 insertions(+), 87 deletions(-)
create mode 100644 backend/connector_auth_v2/openai_oauth.py
create mode 100644 frontend/src/components/oauth-ds/openai/OpenAIOAuthButton.css
create mode 100644 frontend/src/components/oauth-ds/openai/OpenAIOAuthButton.jsx
create mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/llm1/openai_oauth.py
create mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openai-oauth.json
create mode 100644 unstract/sdk1/src/unstract/sdk1/auth/__init__.py
create mode 100644 unstract/sdk1/src/unstract/sdk1/auth/openai_oauth.py
create mode 100644 unstract/sdk1/tests/test_openai_oauth.py
diff --git a/backend/adapter_processor_v2/adapter_processor.py b/backend/adapter_processor_v2/adapter_processor.py
index a9c7f40e32..3db703096c 100644
--- a/backend/adapter_processor_v2/adapter_processor.py
+++ b/backend/adapter_processor_v2/adapter_processor.py
@@ -8,6 +8,13 @@
from django.core.exceptions import ObjectDoesNotExist
from platform_settings_v2.platform_auth_service import PlatformAuthenticationService
from tenant_account_v2.organization_member_service import OrganizationMemberService
+from unstract.sdk1.adapters.adapterkit import Adapterkit
+from unstract.sdk1.adapters.base import Adapter
+from unstract.sdk1.adapters.x2text.constants import X2TextConstants
+from unstract.sdk1.constants import AdapterTypes
+from unstract.sdk1.embedding import EmbeddingCompat
+from unstract.sdk1.exceptions import SdkError
+from unstract.sdk1.llm import LLM
from adapter_processor_v2.constants import AdapterKeys, AllowedDomains
from adapter_processor_v2.exceptions import (
@@ -16,13 +23,6 @@
InValidAdapterId,
TestAdapterError,
)
-from unstract.sdk1.adapters.adapterkit import Adapterkit
-from unstract.sdk1.adapters.base import Adapter
-from unstract.sdk1.adapters.x2text.constants import X2TextConstants
-from unstract.sdk1.constants import AdapterTypes
-from unstract.sdk1.embedding import EmbeddingCompat
-from unstract.sdk1.exceptions import SdkError
-from unstract.sdk1.llm import LLM
from .models import AdapterInstance, UserDefaultAdapter
@@ -46,6 +46,9 @@ def get_json_schema(adapter_id: str) -> dict[str, Any]:
schema_details[AdapterKeys.JSON_SCHEMA] = json.loads(
updated_adapters[0].get(AdapterKeys.JSON_SCHEMA)
)
+ for key in ("oauth", "oauth_provider", "python_social_auth_backend"):
+ if key in updated_adapters[0]:
+ schema_details[key] = updated_adapters[0][key]
else:
logger.error(f"Invalid adapter Id : {adapter_id} while fetching JSON Schema")
raise InValidAdapterId()
@@ -68,8 +71,7 @@ def get_all_supported_adapters(user_email: str, type: str) -> list[dict[Any, Any
if not is_special_user and adapter_id.startswith("noOp"):
continue
- supported_adapters.append(
- {
+ adapter_details = {
AdapterKeys.ID: adapter_id,
AdapterKeys.NAME: each_adapter.get(AdapterKeys.NAME),
AdapterKeys.DESCRIPTION: each_adapter.get(AdapterKeys.DESCRIPTION),
@@ -77,7 +79,10 @@ def get_all_supported_adapters(user_email: str, type: str) -> list[dict[Any, Any
AdapterKeys.ADAPTER_TYPE: each_adapter.get(AdapterKeys.ADAPTER_TYPE),
AdapterKeys.DOC_URL: each_adapter.get(AdapterKeys.DOC_URL, ""),
}
- )
+ for key in ("oauth", "oauth_provider", "python_social_auth_backend"):
+ if key in each_adapter:
+ adapter_details[key] = each_adapter[key]
+ supported_adapters.append(adapter_details)
return supported_adapters
@staticmethod
diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py
index 8209dd75c7..123be31f45 100644
--- a/backend/adapter_processor_v2/serializers.py
+++ b/backend/adapter_processor_v2/serializers.py
@@ -2,6 +2,9 @@
from typing import Any
from account_v2.serializer import UserSerializer
+from backend.constants import FieldLengthConstants as FLC
+from backend.serializers import AuditSerializer
+from connector_auth_v2.openai_oauth import redact_openai_oauth_metadata
from cryptography.fernet import Fernet
from django.conf import settings
from rest_framework import serializers
@@ -10,14 +13,13 @@
serialize_group_refs,
serialize_owner_refs,
)
+from unstract.sdk1.auth.openai_oauth import is_openai_oauth_adapter
+from unstract.sdk1.constants import AdapterTypes
+from unstract.sdk1.constants import Common as common
from utils.input_sanitizer import validate_name_field, validate_no_html_tags
from adapter_processor_v2.adapter_processor import AdapterProcessor
from adapter_processor_v2.constants import AdapterKeys
-from backend.constants import FieldLengthConstants as FLC
-from backend.serializers import AuditSerializer
-from unstract.sdk1.constants import AdapterTypes
-from unstract.sdk1.constants import Common as common
from .models import AdapterInstance, UserDefaultAdapter
@@ -87,6 +89,11 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]:
rep.pop(AdapterKeys.ADAPTER_METADATA_B)
adapter_metadata = instance.metadata
+ if is_openai_oauth_adapter(instance.adapter_id):
+ # OAuth tokens and account identity stay encrypted in the database
+ # and are never returned to the browser after the initial login.
+ adapter_metadata = redact_openai_oauth_metadata(adapter_metadata)
+
# Hide unstract_key when use_platform_provided_unstract_key is True
if (
adapter_metadata.get("use_platform_provided_unstract_key") is True
diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py
index f2aef82b0c..c69b0e0fdb 100644
--- a/backend/adapter_processor_v2/views.py
+++ b/backend/adapter_processor_v2/views.py
@@ -3,6 +3,7 @@
from typing import Any
from account_v2.models import User
+from connector_auth_v2.openai_oauth import OpenAIOAuthService
from django.db import IntegrityError
from django.db.models import ProtectedError, QuerySet
from django.http import HttpRequest
@@ -19,7 +20,7 @@
from plugins import get_plugin
from rest_framework import status
from rest_framework.decorators import action
-from rest_framework.exceptions import PermissionDenied
+from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
@@ -27,6 +28,11 @@
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from tenant_account_v2.organization_member_service import OrganizationMemberService
from tool_instance_v2.models import ToolInstance
+from unstract.sdk1.auth.openai_oauth import (
+ OPENAI_OAUTH_PRIVATE_FIELDS,
+ OpenAIOAuthError,
+ is_openai_oauth_adapter,
+)
from utils.filtering import FilterHelper
from utils.pagination import OptionalPagination
from utils.user_context import UserContext
@@ -60,6 +66,63 @@
logger = logging.getLogger(__name__)
+def _prepare_openai_oauth_payload(
+ request: Request,
+ payload: Any,
+ existing_metadata: dict[str, Any] | None = None,
+) -> tuple[Any, str | None]:
+ """Inject credentials from one owned OAuth login into an adapter payload."""
+ adapter_id = payload.get(AdapterKeys.ADAPTER_ID)
+ if not is_openai_oauth_adapter(adapter_id):
+ return payload, None
+
+ submitted_metadata = payload.get(AdapterKeys.ADAPTER_METADATA)
+ if submitted_metadata is None and existing_metadata is not None:
+ adapter_metadata = dict(existing_metadata)
+ elif isinstance(submitted_metadata, dict):
+ adapter_metadata = dict(submitted_metadata)
+ else:
+ adapter_metadata = {}
+
+ # Tokens/account identity are always sourced from the server-side login
+ # session or the already-encrypted row. Never trust values sent in JSON.
+ for key in OPENAI_OAUTH_PRIVATE_FIELDS:
+ adapter_metadata.pop(key, None)
+ adapter_metadata.pop("oauth_authenticated", None)
+ adapter_metadata.pop("oauth_account_label", None)
+
+ oauth_key = request.query_params.get("oauth-key")
+ if oauth_key:
+ try:
+ credentials = OpenAIOAuthService.credentials_for_request(oauth_key, request)
+ except OpenAIOAuthError as exc:
+ raise ValidationError({"oauth-key": str(exc)}) from exc
+ adapter_metadata.update(credentials)
+ elif existing_metadata is not None:
+ for key in OPENAI_OAUTH_PRIVATE_FIELDS:
+ if key in existing_metadata:
+ adapter_metadata[key] = existing_metadata[key]
+ else:
+ raise ValidationError(
+ {"oauth-key": "OpenAI OAuth authentication is required for this adapter."}
+ )
+
+ payload[AdapterKeys.ADAPTER_METADATA] = adapter_metadata
+ return payload, oauth_key
+
+
+def _consume_openai_oauth_key(cache_key: str | None, request: Request) -> None:
+ """Best-effort cleanup after credentials are durably stored."""
+ if not cache_key:
+ return
+ try:
+ OpenAIOAuthService.consume(cache_key, request)
+ except OpenAIOAuthError:
+ # The adapter row is already the durable credential store. A cache
+ # expiry/race must not turn a successful create/update into a 500.
+ logger.warning("Could not consume OpenAI OAuth hand-off session")
+
+
class DefaultAdapterViewSet(ModelViewSet):
versioning_class = URLPathVersioning
serializer_class = DefaultAdapterSerializer
@@ -127,10 +190,13 @@ def get_adapter_schema(
def test(self, request: Request) -> Response:
"""Tests the connector against the credentials passed."""
- serializer: AdapterInstanceSerializer = self.get_serializer(data=request.data)
+ payload = request.data.copy()
+ payload, _ = _prepare_openai_oauth_payload(request, payload)
+ serializer: AdapterInstanceSerializer = self.get_serializer(data=payload)
serializer.is_valid(raise_exception=True)
adapter_id = serializer.validated_data.get(AdapterKeys.ADAPTER_ID)
adapter_metadata = serializer.validated_data.get(AdapterKeys.ADAPTER_METADATA)
+ adapter_metadata = dict(adapter_metadata or {})
adapter_metadata[AdapterKeys.ADAPTER_TYPE] = serializer.validated_data.get(
AdapterKeys.ADAPTER_TYPE
)
@@ -237,10 +303,12 @@ def _enforce_llm_creation_restriction(request: Any, adapter_type: str) -> None:
)
def create(self, request: Any) -> Response:
- serializer = self.get_serializer(data=request.data)
+ payload = request.data.copy()
+ payload, oauth_key = _prepare_openai_oauth_payload(request, payload)
+ serializer = self.get_serializer(data=payload)
use_platform_unstract_key = False
- adapter_metadata = request.data.get(AdapterKeys.ADAPTER_METADATA)
+ adapter_metadata = payload.get(AdapterKeys.ADAPTER_METADATA)
if adapter_metadata and adapter_metadata.get(
AdapterKeys.PLATFORM_PROVIDED_UNSTRACT_KEY, False
):
@@ -312,6 +380,9 @@ def create(self, request: Any) -> Response:
user_default_adapter.save()
+ # The encrypted adapter row is now the durable credential store.
+ _consume_openai_oauth_key(oauth_key, request)
+
except IntegrityError:
raise DuplicateAdapterNameError(
name=serializer.validated_data.get(AdapterKeys.ADAPTER_NAME)
@@ -410,7 +481,10 @@ def partial_update(
adapter = self.get_object()
before = self.snapshot_share_axes(adapter)
- response = super().partial_update(request, *args, **kwargs)
+ if is_openai_oauth_adapter(adapter.adapter_id):
+ response = self._update_openai_oauth(request, adapter, partial=True)
+ else:
+ response = super().partial_update(request, *args, **kwargs)
if response.status_code == 200 and notification_plugin:
self._notify_shared_users(adapter, before, request.data, request.user)
return response
@@ -555,6 +629,13 @@ def list_of_shared_users(self, request: HttpRequest, pk: Any = None) -> Response
def update(
self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any]
) -> Response:
+ # OAuth adapters carry their credentials in the encrypted metadata row;
+ # inject a newly completed login or preserve the existing account when
+ # a metadata-only edit does not include a new login session.
+ adapter = self.get_object()
+ if is_openai_oauth_adapter(adapter.adapter_id):
+ return self._update_openai_oauth(request, adapter, partial=False)
+
# Check if adapter metadata is being updated and contains the platform key flag
use_platform_unstract_key = False
adapter_metadata = request.data.get(AdapterKeys.ADAPTER_METADATA)
@@ -565,9 +646,6 @@ def update(
use_platform_unstract_key = True
logger.error(f"Platform key flag detected: {use_platform_unstract_key}")
- # Get the adapter instance for update
- adapter = self.get_object()
-
if use_platform_unstract_key:
logger.error("Processing adapter with platform key")
serializer = self.get_serializer(adapter, data=request.data, partial=True)
@@ -597,6 +675,19 @@ def update(
# For non-platform-key cases, use the default update behavior
return super().update(request, *args, **kwargs)
+ def _update_openai_oauth(
+ self, request: Request, adapter: AdapterInstance, *, partial: bool
+ ) -> Response:
+ payload = request.data.copy()
+ payload, oauth_key = _prepare_openai_oauth_payload(
+ request, payload, existing_metadata=adapter.metadata
+ )
+ serializer = self.get_serializer(adapter, data=payload, partial=partial)
+ serializer.is_valid(raise_exception=True)
+ serializer.save()
+ _consume_openai_oauth_key(oauth_key, request)
+ return Response(serializer.data)
+
@action(detail=True, methods=["get"])
def adapter_info(self, request: HttpRequest, pk: uuid) -> Response:
adapter = self.get_object()
diff --git a/backend/connector_auth_v2/openai_oauth.py b/backend/connector_auth_v2/openai_oauth.py
new file mode 100644
index 0000000000..68f851e364
--- /dev/null
+++ b/backend/connector_auth_v2/openai_oauth.py
@@ -0,0 +1,345 @@
+"""Server-side device login and credential hand-off for OpenAI OAuth."""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+import uuid
+from datetime import UTC, datetime
+from typing import Any
+
+import httpx
+from cryptography.fernet import Fernet, InvalidToken
+from django.conf import settings
+from django.core.cache import cache
+from rest_framework.request import Request
+from unstract.sdk1.auth.openai_oauth import (
+ OPENAI_OAUTH_CLIENT_ID,
+ OPENAI_OAUTH_DEVICE_REDIRECT_URI,
+ OPENAI_OAUTH_DEVICE_TOKEN_URL,
+ OPENAI_OAUTH_DEVICE_USERCODE_URL,
+ OPENAI_OAUTH_DEVICE_VERIFICATION_URL,
+ OPENAI_OAUTH_PRIVATE_FIELDS,
+ OPENAI_OAUTH_TOKEN_URL,
+ OpenAIOAuthError,
+ extract_account_id,
+ extract_email,
+ refresh_openai_oauth_metadata,
+)
+from utils.user_session import UserSessionUtils
+
+_CACHE_PREFIX = "openai-oauth:"
+_DEFAULT_STATE_TTL_SECONDS = 900
+_STATE_TTL_SECONDS = int(
+ os.environ.get(
+ "OPENAI_OAUTH_STATE_TTL_SECONDS", str(_DEFAULT_STATE_TTL_SECONDS)
+ )
+)
+
+
+class OpenAIOAuthSessionError(OpenAIOAuthError):
+ """Raised for an invalid, expired, or unauthorized browser login session."""
+
+
+def _safe_response_json(response: httpx.Response, operation: str) -> dict[str, Any]:
+ if not 200 <= response.status_code < 300:
+ raise OpenAIOAuthError(
+ f"OpenAI OAuth {operation} failed with status {response.status_code}"
+ )
+ try:
+ payload = response.json()
+ except (ValueError, json.JSONDecodeError) as exc:
+ raise OpenAIOAuthError(
+ f"OpenAI OAuth {operation} returned an invalid response"
+ ) from exc
+ if not isinstance(payload, dict):
+ raise OpenAIOAuthError(
+ f"OpenAI OAuth {operation} returned an invalid response"
+ )
+ return payload
+
+
+def _post_json(url: str, *, json_body: dict[str, Any], operation: str) -> dict[str, Any]:
+ try:
+ response = httpx.post(url, json=json_body, timeout=15.0)
+ except httpx.HTTPError as exc:
+ raise OpenAIOAuthError(
+ f"OpenAI OAuth {operation} could not reach the authorization server"
+ ) from exc
+ return _safe_response_json(response, operation)
+
+
+def _post_form(
+ url: str, *, form_data: dict[str, str], operation: str
+) -> dict[str, Any]:
+ try:
+ response = httpx.post(
+ url,
+ data=form_data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ timeout=15.0,
+ )
+ except httpx.HTTPError as exc:
+ raise OpenAIOAuthError(
+ f"OpenAI OAuth {operation} could not reach the authorization server"
+ ) from exc
+ return _safe_response_json(response, operation)
+
+
+def _as_expiry_seconds(value: object) -> float | None:
+ if value is None:
+ return None
+ if isinstance(value, (int, float)):
+ return float(value)
+ if isinstance(value, str):
+ try:
+ return float(value)
+ except ValueError:
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
+ except ValueError:
+ return None
+ return None
+
+
+def _account_label(account_id: str, email: str | None) -> str:
+ if email:
+ return email
+ suffix = account_id[-8:] if len(account_id) > 8 else account_id
+ return f"OpenAI account ({suffix})"
+
+
+class OpenAIOAuthService:
+ """Own one short-lived OAuth login session per browser/account."""
+
+ @staticmethod
+ def _identity(request: Request) -> tuple[str, str]:
+ user_id = getattr(request.user, "pk", None) or getattr(request.user, "id", None)
+ organization_id = UserSessionUtils.get_organization_id(request)
+ if user_id is None or not organization_id:
+ raise OpenAIOAuthSessionError(
+ "An authenticated organization session is required for OpenAI OAuth"
+ )
+ return str(user_id), str(organization_id)
+
+ @staticmethod
+ def _encrypt(credentials: dict[str, Any]) -> str:
+ fernet = Fernet(str(settings.ENCRYPTION_KEY).encode("utf-8"))
+ return fernet.encrypt(json.dumps(credentials).encode("utf-8")).decode("utf-8")
+
+ @staticmethod
+ def _decrypt(value: str) -> dict[str, Any]:
+ try:
+ fernet = Fernet(str(settings.ENCRYPTION_KEY).encode("utf-8"))
+ credentials = json.loads(fernet.decrypt(value.encode("utf-8")))
+ except (InvalidToken, ValueError, json.JSONDecodeError) as exc:
+ raise OpenAIOAuthSessionError(
+ "OpenAI OAuth login session is no longer valid"
+ ) from exc
+ if not isinstance(credentials, dict):
+ raise OpenAIOAuthSessionError(
+ "OpenAI OAuth login session is no longer valid"
+ )
+ return credentials
+
+ @classmethod
+ def _save_state(cls, cache_key: str, state: dict[str, Any]) -> None:
+ expiry = _as_expiry_seconds(state.get("expires_at"))
+ ttl = _STATE_TTL_SECONDS
+ if expiry is not None:
+ ttl = min(ttl, max(30, int(expiry - time.time())))
+ cache.set(cache_key, state, max(ttl, 30))
+
+ @classmethod
+ def _get_owned_state(cls, cache_key: str, request: Request) -> dict[str, Any]:
+ if not cache_key or not cache_key.startswith(_CACHE_PREFIX):
+ raise OpenAIOAuthSessionError("OpenAI OAuth login session is invalid")
+ user_id, organization_id = cls._identity(request)
+ state = cache.get(cache_key)
+ if not isinstance(state, dict):
+ raise OpenAIOAuthSessionError(
+ "OpenAI OAuth login session was not found or has expired"
+ )
+ if state.get("owner_id") != user_id or state.get("organization_id") != organization_id:
+ raise OpenAIOAuthSessionError(
+ "OpenAI OAuth login session was not found or is not owned by this user"
+ )
+ expiry = _as_expiry_seconds(state.get("expires_at"))
+ if expiry is not None and expiry <= time.time():
+ cache.delete(cache_key)
+ raise OpenAIOAuthSessionError("OpenAI OAuth login session has expired")
+ return state
+
+ @classmethod
+ def start(cls, request: Request) -> dict[str, Any]:
+ user_id, organization_id = cls._identity(request)
+ payload = _post_json(
+ OPENAI_OAUTH_DEVICE_USERCODE_URL,
+ json_body={"client_id": OPENAI_OAUTH_CLIENT_ID},
+ operation="device login start",
+ )
+ device_auth_id = payload.get("device_auth_id")
+ user_code = payload.get("user_code") or payload.get("usercode")
+ if not isinstance(device_auth_id, str) or not device_auth_id:
+ raise OpenAIOAuthError("OpenAI OAuth device login returned no device id")
+ if not isinstance(user_code, str) or not user_code:
+ raise OpenAIOAuthError("OpenAI OAuth device login returned no user code")
+
+ try:
+ interval = max(1, int(payload.get("interval", 5)))
+ except (TypeError, ValueError):
+ interval = 5
+ expires_at = payload.get("expires_at")
+ if not isinstance(expires_at, str):
+ expires_at = datetime.fromtimestamp(
+ time.time() + _STATE_TTL_SECONDS, tz=UTC
+ ).isoformat()
+
+ cache_key = f"{_CACHE_PREFIX}{uuid.uuid4().hex}"
+ cls._save_state(
+ cache_key,
+ {
+ "status": "pending",
+ "owner_id": user_id,
+ "organization_id": organization_id,
+ "device_auth_id": device_auth_id,
+ "user_code": user_code,
+ "poll_interval": interval,
+ "expires_at": expires_at,
+ },
+ )
+ return {
+ "cache_key": cache_key,
+ "verification_url": OPENAI_OAUTH_DEVICE_VERIFICATION_URL,
+ "user_code": user_code,
+ "expires_at": expires_at,
+ "poll_interval": interval,
+ }
+
+ @classmethod
+ def _success_response(cls, state: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "status": "success",
+ "account_label": state.get("account_label", "OpenAI account"),
+ }
+
+ @classmethod
+ def poll(cls, request: Request, cache_key: str) -> dict[str, Any]:
+ state = cls._get_owned_state(cache_key, request)
+ if state.get("status") == "success":
+ return cls._success_response(state)
+
+ try:
+ response = httpx.post(
+ OPENAI_OAUTH_DEVICE_TOKEN_URL,
+ json={
+ "device_auth_id": state["device_auth_id"],
+ "user_code": state["user_code"],
+ },
+ timeout=15.0,
+ )
+ except httpx.HTTPError as exc:
+ raise OpenAIOAuthError(
+ "OpenAI OAuth device login could not reach the authorization server"
+ ) from exc
+
+ # The official device endpoint uses 403/404 while the user has not
+ # finished entering the code. Keep the browser poll alive for those
+ # expected responses (and for rate limiting).
+ if response.status_code in {403, 404, 429}:
+ return {
+ "status": "pending",
+ "poll_interval": state.get("poll_interval", 5),
+ }
+ device_result = _safe_response_json(response, "device login poll")
+ authorization_code = device_result.get("authorization_code")
+ code_verifier = device_result.get("code_verifier")
+ if not isinstance(authorization_code, str) or not isinstance(
+ code_verifier, str
+ ):
+ raise OpenAIOAuthError(
+ "OpenAI OAuth device login returned incomplete authorization data"
+ )
+
+ token_data = _post_form(
+ OPENAI_OAUTH_TOKEN_URL,
+ form_data={
+ "grant_type": "authorization_code",
+ "code": authorization_code,
+ "redirect_uri": OPENAI_OAUTH_DEVICE_REDIRECT_URI,
+ "client_id": OPENAI_OAUTH_CLIENT_ID,
+ "code_verifier": code_verifier,
+ },
+ operation="token exchange",
+ )
+ access_token = token_data.get("access_token")
+ refresh_token = token_data.get("refresh_token")
+ id_token = token_data.get("id_token")
+ if not all(isinstance(value, str) and value for value in (access_token, refresh_token)):
+ raise OpenAIOAuthError(
+ "OpenAI OAuth token exchange returned incomplete credentials"
+ )
+
+ account_id = extract_account_id(id_token, access_token)
+ if not account_id:
+ raise OpenAIOAuthError(
+ "OpenAI OAuth token exchange returned no ChatGPT account"
+ )
+ email = extract_email(id_token, access_token)
+ try:
+ expires_in = float(token_data.get("expires_in", 3600))
+ except (TypeError, ValueError):
+ expires_in = 3600
+ credentials = {
+ "oauth_access_token": access_token,
+ "oauth_refresh_token": refresh_token,
+ "oauth_id_token": id_token,
+ "oauth_account_id": account_id,
+ "oauth_account_email": email,
+ "oauth_expires_at": time.time() + expires_in,
+ "oauth_authenticated": True,
+ }
+ state.update(
+ {
+ "status": "success",
+ "credentials": cls._encrypt(credentials),
+ "account_label": _account_label(account_id, email),
+ }
+ )
+ cls._save_state(cache_key, state)
+ return cls._success_response(state)
+
+ @classmethod
+ def credentials_for_request(
+ cls, cache_key: str, request: Request
+ ) -> dict[str, Any]:
+ state = cls._get_owned_state(cache_key, request)
+ if state.get("status") != "success" or not state.get("credentials"):
+ raise OpenAIOAuthSessionError("Complete OpenAI OAuth authentication first")
+ credentials = cls._decrypt(state["credentials"])
+ refreshed = refresh_openai_oauth_metadata(credentials)
+ if refreshed != credentials:
+ state["credentials"] = cls._encrypt(refreshed)
+ cls._save_state(cache_key, state)
+ return refreshed
+
+ @classmethod
+ def consume(cls, cache_key: str, request: Request) -> None:
+ # Validate ownership before deleting so a leaked cache key cannot be
+ # used to consume another user's pending login.
+ cls._get_owned_state(cache_key, request)
+ cache.delete(cache_key)
+
+
+def redact_openai_oauth_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
+ """Return the non-secret account state safe for API responses."""
+ redacted = {
+ key: value for key, value in metadata.items() if key not in OPENAI_OAUTH_PRIVATE_FIELDS
+ }
+ redacted["oauth_authenticated"] = bool(metadata.get("oauth_access_token"))
+ account_id = metadata.get("oauth_account_id")
+ email = metadata.get("oauth_account_email")
+ if isinstance(account_id, str):
+ redacted["oauth_account_label"] = _account_label(account_id, email)
+ return redacted
diff --git a/backend/connector_auth_v2/urls.py b/backend/connector_auth_v2/urls.py
index 55337ad20f..b4532ee3d9 100644
--- a/backend/connector_auth_v2/urls.py
+++ b/backend/connector_auth_v2/urls.py
@@ -8,6 +8,8 @@
"get": "cache_key",
}
)
+openai_oauth_start = ConnectorAuthViewSet.as_view({"post": "openai_start"})
+openai_oauth_poll = ConnectorAuthViewSet.as_view({"get": "openai_poll"})
urlpatterns = format_suffix_patterns(
[
@@ -17,5 +19,7 @@
connector_auth_cache,
name="connector-cache",
),
+ path("oauth/openai/start/", openai_oauth_start, name="openai-oauth-start"),
+ path("oauth/openai/poll/", openai_oauth_poll, name="openai-oauth-poll"),
]
)
diff --git a/backend/connector_auth_v2/views.py b/backend/connector_auth_v2/views.py
index 4d515563b6..a17bd99228 100644
--- a/backend/connector_auth_v2/views.py
+++ b/backend/connector_auth_v2/views.py
@@ -6,10 +6,15 @@
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.versioning import URLPathVersioning
+from unstract.sdk1.auth.openai_oauth import OpenAIOAuthError
from utils.user_session import UserSessionUtils
from connector_auth_v2.constants import SocialAuthConstants
from connector_auth_v2.exceptions import KeyNotConfigured
+from connector_auth_v2.openai_oauth import (
+ OpenAIOAuthService,
+ OpenAIOAuthSessionError,
+)
logger = logging.getLogger(__name__)
@@ -47,3 +52,44 @@ def cache_key(
status=status.HTTP_200_OK,
data={"cache_key": f"{cache_key}"},
)
+
+ @staticmethod
+ def _require_authenticated(request: Request) -> Response | None:
+ if not getattr(request.user, "is_authenticated", False):
+ return Response(
+ {"message": "Authentication is required for OpenAI OAuth."},
+ status=status.HTTP_401_UNAUTHORIZED,
+ )
+ return None
+
+ def openai_start(self, request: Request) -> Response:
+ """Start a server-side OpenAI device-code login."""
+ if unauthorized := self._require_authenticated(request):
+ return unauthorized
+ try:
+ return Response(
+ OpenAIOAuthService.start(request), status=status.HTTP_200_OK
+ )
+ except OpenAIOAuthSessionError as exc:
+ return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
+ except OpenAIOAuthError as exc:
+ logger.warning("OpenAI OAuth device login start failed: %s", exc)
+ return Response(
+ {"message": str(exc)}, status=status.HTTP_502_BAD_GATEWAY
+ )
+
+ def openai_poll(self, request: Request) -> Response:
+ """Poll one OpenAI device-code login and exchange it when complete."""
+ if unauthorized := self._require_authenticated(request):
+ return unauthorized
+ cache_key = request.query_params.get("oauth-key")
+ try:
+ result = OpenAIOAuthService.poll(request, cache_key or "")
+ return Response(result, status=status.HTTP_200_OK)
+ except OpenAIOAuthSessionError as exc:
+ return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
+ except OpenAIOAuthError as exc:
+ logger.warning("OpenAI OAuth device login poll failed: %s", exc)
+ return Response(
+ {"message": str(exc)}, status=status.HTTP_502_BAD_GATEWAY
+ )
diff --git a/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx b/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx
index 5bb0d6f632..e26c88a8ad 100644
--- a/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx
+++ b/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx
@@ -56,7 +56,10 @@ function ConfigureDs({
// Determine if this is a new or existing connector
const hasOAuthCredentials =
- metadata && (metadata.access_token || (metadata.provider && metadata.uid));
+ metadata &&
+ (metadata.access_token ||
+ (metadata.provider && metadata.uid) ||
+ metadata.oauth_authenticated);
const isExistingConnector = Boolean(editItemId || hasOAuthCredentials);
// Determine if OAuth authentication method is selected
@@ -176,7 +179,11 @@ function ConfigureDs({
(status !== "success" || !cacheKey?.length)
) {
const providerName =
- oAuthProvider === "google-oauth2" ? "Google" : "OAuth provider";
+ oAuthProvider === "google-oauth2"
+ ? "Google"
+ : oAuthProvider === "openai-oauth"
+ ? "OpenAI"
+ : "OAuth provider";
setAlertDetails({
type: "error",
content: `OAuth authentication required. Please sign in with ${providerName} first.`,
@@ -211,10 +218,14 @@ function ConfigureDs({
}
if (oAuthProvider?.length > 0 && isOAuthMethodSelected()) {
- body["connector_metadata"] = {
- ...body["connector_metadata"],
- ...{ "oauth-key": cacheKey },
- };
+ if (isConnector) {
+ body["connector_metadata"] = {
+ ...body["connector_metadata"],
+ ...{ "oauth-key": cacheKey },
+ };
+ } else {
+ url = `${url}?oauth-key=${encodeURIComponent(cacheKey)}`;
+ }
}
const requestOptions = {
@@ -311,7 +322,11 @@ function ConfigureDs({
url = `${url}${editItemId}/`;
}
- if (oAuthProvider?.length > 0 && isOAuthMethodSelected()) {
+ if (
+ oAuthProvider?.length > 0 &&
+ isOAuthMethodSelected() &&
+ cacheKey?.length
+ ) {
const encodedCacheKey = encodeURIComponent(cacheKey);
url = url + `?oauth-key=${encodedCacheKey}`;
}
@@ -342,6 +357,7 @@ function ConfigureDs({
if (oAuthProvider?.length > 0 && isOAuthMethodSelected()) {
localStorage.removeItem(oauthCacheKey);
localStorage.removeItem(oauthStatusKey);
+ localStorage.removeItem(`oauth-device-${selectedSourceId}`);
}
setOpen(false);
diff --git a/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx b/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx
index cc40c50bbf..9019e5d17c 100644
--- a/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx
+++ b/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx
@@ -1,5 +1,5 @@
import PropTypes from "prop-types";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { Typography } from "@/components/ui/shims/antd-typography";
import { getBaseUrl, O_AUTH_PROVIDERS } from "../../../helpers/GetStaticData";
@@ -8,6 +8,7 @@ import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
import { useAlertStore } from "../../../store/alert-store";
import GoogleOAuthButton from "../google/GoogleOAuthButton.jsx";
import MicrosoftOAuthButton from "../microsoft/MicrosoftOAuthButton.jsx";
+import OpenAIOAuthButton from "../openai/OpenAIOAuthButton.jsx";
function OAuthDs({
oAuthProvider,
@@ -36,6 +37,9 @@ function OAuthDs({
if (oAuthProvider === O_AUTH_PROVIDERS.GOOGLE) {
return "Authenticate with Google";
}
+ if (oAuthProvider === O_AUTH_PROVIDERS.OPENAI) {
+ return "Sign in with OpenAI";
+ }
return "Authenticate";
};
@@ -45,6 +49,22 @@ function OAuthDs({
// Initialize from connector-specific status
return localStorage.getItem(oauthStatusKey);
});
+ const [loginCacheKey, setLoginCacheKey] = useState(() =>
+ localStorage.getItem(oauthCacheKey),
+ );
+ const [deviceLogin, setDeviceLogin] = useState(() => {
+ try {
+ return JSON.parse(localStorage.getItem(`oauth-device-${selectedSourceId}`));
+ } catch {
+ return null;
+ }
+ });
+
+ const updateOAuthStatus = useCallback((newStatus) => {
+ setOAuthStatus(newStatus);
+ setStatus(newStatus);
+ localStorage.setItem(oauthStatusKey, newStatus);
+ }, [oauthStatusKey, setStatus]);
useEffect(() => {
const handleStorageChange = () => {
@@ -60,15 +80,29 @@ function OAuthDs({
// Load persisted cache key if available
const persistedCacheKey = localStorage.getItem(oauthCacheKey);
+ setLoginCacheKey(persistedCacheKey || null);
if (persistedCacheKey) {
setCacheKey(persistedCacheKey);
+ } else {
+ setCacheKey("");
}
// Set initial status from connector-specific status
const connectorStatus = localStorage.getItem(oauthStatusKey);
- if (connectorStatus) {
- setStatus(connectorStatus);
- setOAuthStatus(connectorStatus);
+ setStatus(connectorStatus || "");
+ setOAuthStatus(connectorStatus || "");
+
+ const persistedDeviceLogin = localStorage.getItem(
+ `oauth-device-${selectedSourceId}`,
+ );
+ if (persistedDeviceLogin) {
+ try {
+ setDeviceLogin(JSON.parse(persistedDeviceLogin));
+ } catch {
+ localStorage.removeItem(`oauth-device-${selectedSourceId}`);
+ }
+ } else {
+ setDeviceLogin(null);
}
return () => {
@@ -77,8 +111,85 @@ function OAuthDs({
};
}, [selectedSourceId, oauthCacheKey, oauthStatusKey, setCacheKey, setStatus]);
+ useEffect(() => {
+ if (
+ oAuthProvider !== O_AUTH_PROVIDERS.OPENAI ||
+ oauthStatus !== "pending" ||
+ !loginCacheKey
+ ) {
+ return undefined;
+ }
+
+ let isActive = true;
+ const pollLogin = async () => {
+ try {
+ const response = await axiosPrivate({
+ method: "GET",
+ url: `/api/v1/oauth/openai/poll/?oauth-key=${encodeURIComponent(loginCacheKey)}`,
+ });
+ if (!isActive) {
+ return;
+ }
+ const result = response?.data || {};
+ if (result.status === "success") {
+ setDeviceLogin((current) => ({
+ ...(current || {}),
+ account_label: result.account_label,
+ }));
+ updateOAuthStatus("success");
+ }
+ } catch (err) {
+ if (!isActive) {
+ return;
+ }
+ const message =
+ err?.response?.data?.message || "OpenAI authentication failed";
+ updateOAuthStatus("error");
+ setAlertDetails(handleException(err, message));
+ }
+ };
+
+ const initialPoll = setTimeout(pollLogin, 1000);
+ const pollInterval = setInterval(pollLogin, 5000);
+ return () => {
+ isActive = false;
+ clearTimeout(initialPoll);
+ clearInterval(pollInterval);
+ };
+ }, [
+ axiosPrivate,
+ handleException,
+ loginCacheKey,
+ oauthStatus,
+ oAuthProvider,
+ setAlertDetails,
+ updateOAuthStatus,
+ ]);
+
const handleOAuth = async () => {
try {
+ if (oAuthProvider === O_AUTH_PROVIDERS.OPENAI) {
+ const response = await axiosPrivate({
+ method: "POST",
+ url: "/api/v1/oauth/openai/start/",
+ });
+ const loginDetails = response?.data || {};
+ const newCacheKey = loginDetails.cache_key;
+ if (!newCacheKey) {
+ throw new Error("OpenAI OAuth did not return a login session");
+ }
+ setLoginCacheKey(newCacheKey);
+ setCacheKey(newCacheKey);
+ localStorage.setItem(oauthCacheKey, newCacheKey);
+ setDeviceLogin(loginDetails);
+ localStorage.setItem(
+ `oauth-device-${selectedSourceId}`,
+ JSON.stringify(loginDetails),
+ );
+ updateOAuthStatus("pending");
+ return;
+ }
+
// Store connector context in sessionStorage for OAuth callback (survives window.open)
sessionStorage.setItem("oauth-current-connector", selectedSourceId);
@@ -136,6 +247,20 @@ function OAuthDs({
);
}
+ if (O_AUTH_PROVIDERS.OPENAI === oAuthProvider) {
+ return (
+