"""Interactive OAuth authentication service for the dev server.
Implements the same PKCE-based authorization code flow as the Python SDK's
``uipath auth`` CLI command. A temporary callback server is spun up on one of
the registered redirect-URI ports (8104, 8055, 42042) to receive the token from
the browser.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import http.server
import json
import logging
import os
import socketserver
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
import httpx
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CLIENT_ID = "36dea5b8-e8bb-423d-8e7b-c808df8f1c00"
SCOPES = (
"offline_access ProcessMining OrchestratorApiUserAccess StudioWebBackend "
"IdentityServerApi ConnectionService DataService DocumentUnderstanding "
"Du.Digitization.Api Du.Classification.Api Du.Extraction.Api "
"Du.Validation.Api EnterpriseContextService Directory JamJamApi "
"LLMGateway LLMOps OMS RCS.FolderAuthorization TM.Projects "
"TM.TestCases TM.Requirements TM.TestSets AutomationSolutions"
)
CANDIDATE_PORTS = [8104, 8055, 42042]
# ---------------------------------------------------------------------------
# PKCE helpers
# ---------------------------------------------------------------------------
def _generate_pkce() -> tuple[str, str]:
"""Return (code_verifier, code_challenge) for PKCE S256."""
verifier = base64.urlsafe_b64encode(os.urandom(32)).decode().rstrip("=")
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
.decode()
.rstrip("=")
)
return verifier, challenge
def _generate_state() -> str:
return base64.urlsafe_b64encode(os.urandom(32)).decode().rstrip("=")
def _parse_jwt_payload(token: str) -> dict[str, Any]:
"""Decode the payload section of a JWT (no signature verification)."""
parts = token.split(".")
if len(parts) < 2:
raise ValueError("Invalid JWT")
padded = parts[1] + "=" * (-len(parts[1]) % 4)
return json.loads(base64.urlsafe_b64decode(padded))
# ---------------------------------------------------------------------------
# Port finder
# ---------------------------------------------------------------------------
def _try_bind_server(
candidates: list[int], handler_class: type
) -> socketserver.TCPServer | None:
"""Try to bind a TCPServer on the first available candidate port."""
socketserver.TCPServer.allow_reuse_address = True
for port in candidates:
try:
httpd = socketserver.TCPServer(("127.0.0.1", port), handler_class)
return httpd
except OSError:
continue
return None
# ---------------------------------------------------------------------------
# Auth state
# ---------------------------------------------------------------------------
@dataclass
class AuthState:
"""Mutable state for the in-progress or completed OAuth flow."""
status: str = "unauthenticated" # unauthenticated | pending | needs_tenant | authenticated | expired
environment: str = "cloud"
token_data: dict[str, Any] = field(default_factory=dict)
tenants: list[dict[str, str]] = field(default_factory=list)
organization: dict[str, str] = field(default_factory=dict)
uipath_url: str | None = None
# Remembered from last session for seamless re-auth
_last_tenant: str | None = None
_last_org: dict[str, str] = field(default_factory=dict)
_last_environment: str | None = None
# internal
_code_verifier: str | None = None
_state: str | None = None
_port: int | None = None
_callback_server: _CallbackServer | None = None
_token_event: asyncio.Event | None = None
_loop: asyncio.AbstractEventLoop | None = None
_wait_task: asyncio.Task[None] | None = None
_auth = AuthState()
def get_auth_state() -> AuthState:
"""Return the module-level auth state singleton."""
return _auth
def reset_auth_state() -> None:
"""Reset the auth state to its initial (unauthenticated) values."""
global _auth
_auth = AuthState()
# ---------------------------------------------------------------------------
# Callback HTML (adapted from SDK index.html)
# ---------------------------------------------------------------------------
_CALLBACK_HTML = """\
UiPath CLI Authentication
Processing authentication request...
Authenticating...
Securely exchanging authorization code for access tokens.
"""
# ---------------------------------------------------------------------------
# Callback HTTP server
# ---------------------------------------------------------------------------
def _make_handler(
html: str,
port: int,
csrf_state: str,
token_callback: Any,
) -> type:
"""Build the HTTP request handler class with closures over request params."""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args: Any) -> None:
pass
def do_GET(self) -> None:
content = html.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(content)))
self._cors()
self.end_headers()
self.wfile.write(content)
def do_POST(self) -> None:
if self.path == f"/set_token/{csrf_state}":
length = int(self.headers.get("Content-Length", 0))
try:
body = json.loads(self.rfile.read(length))
except (json.JSONDecodeError, ValueError):
self.send_error(400, "Malformed JSON")
return
self.send_response(200)
self._cors()
self.end_headers()
self.wfile.write(b"OK")
# Small delay so the browser gets the response before we shut down
time.sleep(0.5)
token_callback(body)
else:
self.send_error(404)
def do_OPTIONS(self) -> None:
self.send_response(200)
self._cors()
self.end_headers()
def _cors(self) -> None:
origin = f"http://localhost:{port}"
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
return Handler
class _CallbackServer:
"""Temporary HTTP server that receives the OAuth token from the browser."""
def __init__(self, httpd: socketserver.TCPServer) -> None:
self.httpd: socketserver.TCPServer | None = httpd
self.port: int = httpd.server_address[1]
self._thread: threading.Thread | None = None
self._shutdown = False
def start(self) -> None:
self._shutdown = False
self._thread = threading.Thread(target=self._serve, daemon=True)
self._thread.start()
def _serve(self) -> None:
try:
while not self._shutdown and self.httpd:
self.httpd.handle_request()
except Exception:
pass
def stop(self) -> None:
self._shutdown = True
if self.httpd:
self.httpd.server_close()
self.httpd = None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
_VALID_ENVIRONMENTS = {"cloud", "staging", "alpha"}
def build_auth_url(environment: str) -> dict[str, Any]:
"""Start the OAuth flow: generate PKCE, spin up callback server, return auth URL."""
if environment not in _VALID_ENVIRONMENTS:
raise ValueError(
f"Invalid environment '{environment}'. Must be one of: {', '.join(sorted(_VALID_ENVIRONMENTS))}"
)
auth = get_auth_state()
# Stop any previous callback server and cancel any pending wait task
if auth._callback_server:
auth._callback_server.stop()
if auth._wait_task and not auth._wait_task.done():
auth._wait_task.cancel()
verifier, challenge = _generate_pkce()
state = _generate_state()
domain = f"https://{environment}.uipath.com"
# Build handler and bind server atomically (no TOCTOU race)
# We need the port first to construct the HTML, so we do a two-step:
# 1. Build a temporary handler, bind to get the port
# 2. Rebuild the handler with the correct HTML, then swap
def on_token(token_data: dict[str, Any]) -> None:
auth.token_data = token_data
if auth._loop and auth._token_event:
auth._loop.call_soon_threadsafe(auth._token_event.set)
# _make_handler needs the port for CORS, and the HTML needs the port for
# redirect_uri. We build a placeholder handler to bind, then rebuild.
placeholder = _make_handler("", 0, state, on_token)
httpd = _try_bind_server(CANDIDATE_PORTS, placeholder)
if httpd is None:
raise RuntimeError(
f"All callback ports ({', '.join(str(p) for p in CANDIDATE_PORTS)}) are in use"
)
port = httpd.server_address[1]
redirect_uri = f"http://localhost:{port}/oidc/login"
html = (
_CALLBACK_HTML.replace("__STATE__", state)
.replace("__CODE_VERIFIER__", verifier)
.replace("__REDIRECT_URI__", redirect_uri)
.replace("__CLIENT_ID__", CLIENT_ID)
.replace("__DOMAIN__", domain)
)
# Replace the handler with the real one containing the correct HTML/port
httpd.RequestHandlerClass = _make_handler(html, port, state, on_token)
query = urlencode(
{
"client_id": CLIENT_ID,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": SCOPES,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
)
auth_url = f"{domain}/identity_/connect/authorize?{query}"
# Store state for later (preserve remembered tenant info for re-auth)
auth.status = "pending"
auth.environment = environment
auth._code_verifier = verifier
auth._state = state
auth._port = port
# Set up asyncio event for signalling
loop = asyncio.get_running_loop()
auth._token_event = asyncio.Event()
auth._loop = loop
server = _CallbackServer(httpd)
server.start()
auth._callback_server = server
auth._wait_task = asyncio.ensure_future(_wait_for_token(auth))
return {"auth_url": auth_url, "status": "pending"}
async def _wait_for_token(auth: AuthState) -> None:
"""Wait for the callback server to receive the token, then resolve tenants."""
try:
if auth._token_event:
await asyncio.wait_for(auth._token_event.wait(), timeout=300)
except asyncio.TimeoutError:
logger.warning("OAuth flow timed out after 5 minutes")
auth.status = "unauthenticated"
return
finally:
if auth._callback_server:
auth._callback_server.stop()
auth._callback_server = None
if not auth.token_data:
auth.status = "unauthenticated"
return
# Resolve tenants
try:
domain = f"https://{auth.environment}.uipath.com"
access_token = auth.token_data.get("access_token", "")
claims = _parse_jwt_payload(access_token)
prt_id = claims.get("prt_id", "")
url = f"{domain}/{prt_id}/portal_/api/filtering/leftnav/tenantsAndOrganizationInfo"
async with httpx.AsyncClient() as client:
resp = await client.get(
url, headers={"Authorization": f"Bearer {access_token}"}
)
resp.raise_for_status()
data = resp.json()
auth.tenants = data.get("tenants", [])
auth.organization = data.get("organization", {})
tenant_names = [t["name"] for t in auth.tenants]
if auth._last_tenant and auth._last_tenant in tenant_names:
# Re-auth: auto-select the previously used tenant
_finalize_tenant(auth, auth._last_tenant)
elif len(auth.tenants) == 1:
# Auto-select single tenant
_finalize_tenant(auth, auth.tenants[0]["name"])
else:
auth.status = "needs_tenant"
except Exception:
logger.exception("Failed to resolve tenants")
auth.status = "unauthenticated"
def select_tenant(tenant_name: str) -> dict[str, Any]:
"""Select a tenant and finalize authentication."""
auth = get_auth_state()
tenant = next((t for t in auth.tenants if t["name"] == tenant_name), None)
if not tenant:
raise ValueError(f"Tenant '{tenant_name}' not found")
_finalize_tenant(auth, tenant_name)
return {"status": "authenticated", "uipath_url": auth.uipath_url}
def _finalize_tenant(auth: AuthState, tenant_name: str) -> None:
"""Write .env and os.environ with the resolved credentials."""
org_name = auth.organization.get("name", "")
domain = f"https://{auth.environment}.uipath.com"
uipath_url = f"{domain}/{org_name}/{tenant_name}"
access_token = auth.token_data.get("access_token", "")
auth.uipath_url = uipath_url
auth.status = "authenticated"
# Remember for seamless re-auth after expiry
auth._last_tenant = tenant_name
auth._last_org = dict(auth.organization)
auth._last_environment = auth.environment
# Update os.environ
os.environ["UIPATH_ACCESS_TOKEN"] = access_token
os.environ["UIPATH_URL"] = uipath_url
# Write/update .env file (preserving comments, blank lines, and ordering)
env_path = Path.cwd() / ".env"
lines: list[str] = []
updated_keys: set[str] = set()
new_values = {"UIPATH_ACCESS_TOKEN": access_token, "UIPATH_URL": uipath_url}
if env_path.exists():
with open(env_path) as f:
for raw_line in f:
stripped = raw_line.strip()
if "=" in stripped and not stripped.startswith("#"):
key = stripped.split("=", 1)[0]
if key in new_values:
lines.append(f"{key}={new_values[key]}\n")
updated_keys.add(key)
continue
lines.append(raw_line)
# Append any keys that weren't already in the file
for key, value in new_values.items():
if key not in updated_keys:
lines.append(f"{key}={value}\n")
with open(env_path, "w") as f:
f.writelines(lines)
def logout() -> None:
"""Clear auth state and env vars."""
auth = get_auth_state()
if auth._callback_server:
auth._callback_server.stop()
os.environ.pop("UIPATH_ACCESS_TOKEN", None)
os.environ.pop("UIPATH_URL", None)
reset_auth_state()
def _check_token_expiry(auth: AuthState) -> None:
"""Flip status to 'expired' if the current token has expired."""
if auth.status != "authenticated":
return
access_token = auth.token_data.get("access_token", "")
if not access_token:
return
try:
claims = _parse_jwt_payload(access_token)
exp = claims.get("exp")
if exp is not None and float(exp) < time.time():
auth.status = "expired"
except Exception:
pass
def get_status() -> dict[str, Any]:
"""Return current auth status for the frontend."""
auth = get_auth_state()
_check_token_expiry(auth)
result: dict[str, Any] = {"status": auth.status}
if auth.status == "needs_tenant":
result["tenants"] = [t["name"] for t in auth.tenants]
if auth.status in ("authenticated", "expired"):
result["uipath_url"] = auth.uipath_url
return result
def restore_session() -> None:
"""Check env/.env for existing credentials and restore auth state if valid."""
auth = get_auth_state()
if auth.status != "unauthenticated":
return
# Try os.environ first, then .env file
access_token = os.environ.get("UIPATH_ACCESS_TOKEN", "")
uipath_url = os.environ.get("UIPATH_URL", "")
if not access_token or not uipath_url:
# Try reading from .env
env_path = Path.cwd() / ".env"
if env_path.exists():
env_vars: dict[str, str] = {}
with open(env_path) as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
key, value = line.split("=", 1)
env_vars[key.strip()] = value.strip()
access_token = access_token or env_vars.get("UIPATH_ACCESS_TOKEN", "")
uipath_url = uipath_url or env_vars.get("UIPATH_URL", "")
if not access_token or not uipath_url:
return
# Check token expiry
try:
claims = _parse_jwt_payload(access_token)
exp = claims.get("exp")
if exp is not None and float(exp) < time.time():
logger.debug("Existing token is expired, skipping restore")
return
except Exception:
return
# Token is valid — restore state
auth.status = "authenticated"
auth.uipath_url = uipath_url
auth.token_data = {"access_token": access_token}
# Ensure os.environ is populated
os.environ["UIPATH_ACCESS_TOKEN"] = access_token
os.environ["UIPATH_URL"] = uipath_url