Skip to content

Commit e0a9a6f

Browse files
authored
feat: okta profile tool (#5184)
* Initial Okta profile tool * Improve * Fix * Improve * Improve * Address EL comments
1 parent fe19407 commit e0a9a6f

File tree

7 files changed

+328
-17
lines changed

7 files changed

+328
-17
lines changed

backend/onyx/configs/app_configs.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@
121121
os.environ.get("OAUTH_CLIENT_SECRET", os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET"))
122122
or ""
123123
)
124+
# OpenID Connect configuration URL for Okta Profile Tool and other OIDC integrations
125+
OPENID_CONFIG_URL = os.environ.get("OPENID_CONFIG_URL") or ""
124126

125127
USER_AUTH_SECRET = os.environ.get("USER_AUTH_SECRET", "")
126128

@@ -617,6 +619,17 @@ def get_current_tz_offset() -> int:
617619

618620
MAX_TOKENS_FOR_FULL_INCLUSION = 4096
619621

622+
623+
#####
624+
# Tool Configs
625+
#####
626+
OKTA_PROFILE_TOOL_ENABLED = (
627+
os.environ.get("OKTA_PROFILE_TOOL_ENABLED", "").lower() == "true"
628+
)
629+
# API token for SSWS auth to Okta Admin API. If set, Users API will be used to enrich profile.
630+
OKTA_API_TOKEN = os.environ.get("OKTA_API_TOKEN") or ""
631+
632+
620633
#####
621634
# Miscellaneous
622635
#####

backend/onyx/llm/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def from_langchain_msg(
7272
message_type = MessageType.USER
7373
elif isinstance(msg, AIMessage):
7474
message_type = MessageType.ASSISTANT
75+
7576
message = message_to_string(msg)
7677
return cls(
7778
message=message,

backend/onyx/tools/built_in_tools.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from sqlalchemy import select
77
from sqlalchemy.orm import Session
88

9+
from onyx.configs.app_configs import OKTA_PROFILE_TOOL_ENABLED
910
from onyx.db.models import Persona
1011
from onyx.db.models import Tool as ToolDBModel
1112
from onyx.tools.tool_implementations.images.image_generation_tool import (
@@ -17,6 +18,9 @@
1718
from onyx.tools.tool_implementations.internet_search.providers import (
1819
get_available_providers,
1920
)
21+
from onyx.tools.tool_implementations.okta_profile.okta_profile_tool import (
22+
OktaProfileTool,
23+
)
2024
from onyx.tools.tool_implementations.search.search_tool import SearchTool
2125
from onyx.tools.tool import Tool
2226
from onyx.utils.logger import setup_logger
@@ -63,6 +67,19 @@ class InCodeToolInfo(TypedDict):
6367
if (bool(get_available_providers()))
6468
else []
6569
),
70+
# Show Okta Profile tool if the environment variables are set
71+
*(
72+
[
73+
InCodeToolInfo(
74+
cls=OktaProfileTool,
75+
description="The Okta Profile Action allows the assistant to fetch user information from Okta.",
76+
in_code_tool_id=OktaProfileTool.__name__,
77+
display_name=OktaProfileTool._DISPLAY_NAME,
78+
)
79+
]
80+
if OKTA_PROFILE_TOOL_ENABLED
81+
else []
82+
),
6683
]
6784

6885

backend/onyx/tools/tool.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
from typing import TYPE_CHECKING
66
from typing import TypeVar
77

8-
from onyx.llm.interfaces import LLM
9-
from onyx.llm.models import PreviousMessage
108
from onyx.utils.special_types import JSON_ro
119

1210

1311
if TYPE_CHECKING:
12+
from onyx.llm.interfaces import LLM
13+
from onyx.llm.models import PreviousMessage
1414
from onyx.chat.prompt_builder.answer_prompt_builder import AnswerPromptBuilder
1515
from onyx.tools.message import ToolCallSummary
1616
from onyx.tools.models import ToolResponse
@@ -53,8 +53,8 @@ def build_tool_message_content(
5353
def get_args_for_non_tool_calling_llm(
5454
self,
5555
query: str,
56-
history: list[PreviousMessage],
57-
llm: LLM,
56+
history: list["PreviousMessage"],
57+
llm: "LLM",
5858
force_run: bool = False,
5959
) -> dict[str, Any] | None:
6060
raise NotImplementedError

backend/onyx/tools/tool_constructor.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
from onyx.configs.app_configs import AZURE_DALLE_API_VERSION
1515
from onyx.configs.app_configs import AZURE_DALLE_DEPLOYMENT_NAME
1616
from onyx.configs.app_configs import IMAGE_MODEL_NAME
17+
from onyx.configs.app_configs import OAUTH_CLIENT_ID
18+
from onyx.configs.app_configs import OAUTH_CLIENT_SECRET
19+
from onyx.configs.app_configs import OKTA_API_TOKEN
20+
from onyx.configs.app_configs import OPENID_CONFIG_URL
1721
from onyx.configs.chat_configs import NUM_INTERNET_SEARCH_CHUNKS
1822
from onyx.configs.chat_configs import NUM_INTERNET_SEARCH_RESULTS
1923
from onyx.configs.model_configs import GEN_AI_TEMPERATURE
@@ -41,6 +45,9 @@
4145
from onyx.tools.tool_implementations.internet_search.internet_search_tool import (
4246
InternetSearchTool,
4347
)
48+
from onyx.tools.tool_implementations.okta_profile.okta_profile_tool import (
49+
OktaProfileTool,
50+
)
4451
from onyx.tools.tool_implementations.search.search_tool import SearchTool
4552
from onyx.tools.utils import compute_all_tool_tokens
4653
from onyx.tools.utils import explicit_tool_calling_supported
@@ -265,6 +272,33 @@ def construct_tools(
265272
"Internet search tool requires a Bing or Exa API key, please contact your Onyx admin to get it added!"
266273
)
267274

275+
# Handle Okta Profile Tool
276+
elif tool_cls.__name__ == OktaProfileTool.__name__:
277+
if not user_oauth_token:
278+
raise ValueError(
279+
"Okta Profile Tool requires user OAuth token but none found"
280+
)
281+
282+
if not all([OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OPENID_CONFIG_URL]):
283+
raise ValueError(
284+
"Okta Profile Tool requires OAuth configuration to be set"
285+
)
286+
287+
if not OKTA_API_TOKEN:
288+
raise ValueError(
289+
"Okta Profile Tool requires OKTA_API_TOKEN to be set"
290+
)
291+
292+
tool_dict[db_tool_model.id] = [
293+
OktaProfileTool(
294+
access_token=user_oauth_token,
295+
client_id=OAUTH_CLIENT_ID,
296+
client_secret=OAUTH_CLIENT_SECRET,
297+
openid_config_url=OPENID_CONFIG_URL,
298+
okta_api_token=OKTA_API_TOKEN,
299+
)
300+
]
301+
268302
# Handle custom tools
269303
elif db_tool_model.openapi_schema:
270304
if not custom_tool_config:
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import json
2+
from collections.abc import Generator
3+
from typing import Any
4+
from urllib.parse import urlparse
5+
6+
import requests
7+
from pydantic import BaseModel
8+
9+
from onyx.llm.interfaces import LLM
10+
from onyx.llm.models import PreviousMessage
11+
from onyx.llm.utils import message_to_string
12+
from onyx.prompts.constants import GENERAL_SEP_PAT
13+
from onyx.tools.base_tool import BaseTool
14+
from onyx.tools.models import ToolResponse
15+
from onyx.utils.logger import setup_logger
16+
from onyx.utils.special_types import JSON_ro
17+
18+
19+
logger = setup_logger()
20+
21+
22+
OKTA_PROFILE_RESPONSE_ID = "okta_profile"
23+
24+
OKTA_TOOL_DESCRIPTION = """
25+
The Okta profile tool can retrieve user profile information from Okta including:
26+
- User ID, status, creation date
27+
- Profile details like name, email, department, location, title, manager, and more
28+
- Account status and activity
29+
"""
30+
31+
32+
class OIDCConfig(BaseModel):
33+
issuer: str
34+
jwks_uri: str | None = None
35+
userinfo_endpoint: str | None = None
36+
introspection_endpoint: str | None = None
37+
token_endpoint: str | None = None
38+
39+
40+
class OktaProfileTool(BaseTool):
41+
_NAME = "get_okta_profile"
42+
_DESCRIPTION = "This tool is used to get the user's profile information."
43+
_DISPLAY_NAME = "Okta Profile"
44+
45+
def __init__(
46+
self,
47+
access_token: str,
48+
client_id: str,
49+
client_secret: str,
50+
openid_config_url: str,
51+
okta_api_token: str,
52+
request_timeout_sec: int = 15,
53+
) -> None:
54+
self.access_token = access_token
55+
self.client_id = client_id
56+
self.client_secret = client_secret
57+
self.openid_config_url = openid_config_url
58+
self.request_timeout_sec = request_timeout_sec
59+
60+
# Extract Okta org URL from OpenID config URL using URL parsing
61+
# OpenID config URL format: https://{org}.okta.com/.well-known/openid_configuration
62+
parsed_url = urlparse(self.openid_config_url)
63+
self.okta_org_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
64+
self.okta_api_token = okta_api_token
65+
66+
self._oidc_config: OIDCConfig | None = None
67+
68+
@property
69+
def name(self) -> str:
70+
return self._NAME
71+
72+
@property
73+
def description(self) -> str:
74+
return self._DESCRIPTION
75+
76+
@property
77+
def display_name(self) -> str:
78+
return self._DISPLAY_NAME
79+
80+
def tool_definition(self) -> dict:
81+
return {
82+
"type": "function",
83+
"function": {
84+
"name": self.name,
85+
"description": self.description,
86+
"parameters": {"type": "object", "properties": {}, "required": []},
87+
},
88+
}
89+
90+
def _load_oidc_config(self) -> OIDCConfig:
91+
if self._oidc_config is not None:
92+
return self._oidc_config
93+
94+
resp = requests.get(self.openid_config_url, timeout=self.request_timeout_sec)
95+
resp.raise_for_status()
96+
data = resp.json()
97+
self._oidc_config = OIDCConfig(**data)
98+
logger.debug(f"Loaded OIDC config from {self.openid_config_url}")
99+
return self._oidc_config
100+
101+
def _call_userinfo(self, access_token: str) -> dict[str, Any] | None:
102+
try:
103+
cfg = self._load_oidc_config()
104+
if not cfg.userinfo_endpoint:
105+
logger.info("OIDC config missing userinfo_endpoint")
106+
return None
107+
headers = {"Authorization": f"Bearer {access_token}"}
108+
r = requests.get(
109+
cfg.userinfo_endpoint, headers=headers, timeout=self.request_timeout_sec
110+
)
111+
if r.status_code == 200:
112+
return r.json()
113+
logger.info(
114+
f"userinfo call returned status {r.status_code}: {r.text[:200]}"
115+
)
116+
return None
117+
except requests.RequestException as e:
118+
logger.debug(f"userinfo request failed: {e}")
119+
return None
120+
121+
def _call_introspection(self, access_token: str) -> dict[str, Any] | None:
122+
try:
123+
cfg = self._load_oidc_config()
124+
if not cfg.introspection_endpoint:
125+
logger.info("OIDC config missing introspection_endpoint")
126+
return None
127+
data = {
128+
"token": access_token,
129+
"token_type_hint": "access_token",
130+
}
131+
auth: tuple[str, str] | None = (self.client_id, self.client_secret)
132+
r = requests.post(
133+
cfg.introspection_endpoint,
134+
data=data,
135+
auth=auth,
136+
headers={"Accept": "application/json"},
137+
timeout=self.request_timeout_sec,
138+
)
139+
if r.status_code == 200:
140+
return r.json()
141+
logger.info(
142+
f"introspection call returned status {r.status_code}: {r.text[:200]}"
143+
)
144+
return None
145+
except requests.RequestException as e:
146+
logger.debug(f"introspection request failed: {e}")
147+
return None
148+
149+
def _call_users_api(self, uid: str) -> dict[str, Any]:
150+
"""Call Okta Users API to fetch full user profile.
151+
152+
Requires okta_org_url and okta_api_token to be set. Raises exception on any error.
153+
"""
154+
if not self.okta_org_url or not self.okta_api_token:
155+
raise ValueError(
156+
"Okta org URL and API token are required for user profile lookup"
157+
)
158+
159+
try:
160+
url = f"{self.okta_org_url.rstrip('/')}/api/v1/users/{uid}"
161+
headers = {"Authorization": f"SSWS {self.okta_api_token}"}
162+
r = requests.get(url, headers=headers, timeout=self.request_timeout_sec)
163+
if r.status_code == 200:
164+
return r.json()
165+
raise ValueError(
166+
f"Okta Users API call failed with status {r.status_code}: {r.text[:200]}"
167+
)
168+
except requests.RequestException as e:
169+
raise ValueError(f"Okta Users API request failed: {e}") from e
170+
171+
def build_tool_message_content(
172+
self, *args: ToolResponse
173+
) -> str | list[str | dict[str, Any]]:
174+
# The tool emits a single aggregated packet; pass it through as compact JSON
175+
profile = args[-1].response if args else {}
176+
return json.dumps(profile)
177+
178+
def get_args_for_non_tool_calling_llm(
179+
self,
180+
query: str,
181+
history: list[PreviousMessage],
182+
llm: LLM,
183+
force_run: bool = False,
184+
) -> dict[str, Any] | None:
185+
if force_run:
186+
return {}
187+
188+
# Use LLM to determine if this tool should be called based on the query
189+
prompt = f"""
190+
You are helping to determine if an Okta profile lookup tool should be called based on a user's query.
191+
192+
{OKTA_TOOL_DESCRIPTION}
193+
194+
Query: {query}
195+
196+
Conversation history:
197+
{GENERAL_SEP_PAT}
198+
{history}
199+
{GENERAL_SEP_PAT}
200+
201+
Should the Okta profile tool be called for this query? Respond with only "YES" or "NO".
202+
""".strip()
203+
response = llm.invoke(prompt)
204+
if response and "YES" in message_to_string(response).upper():
205+
return {}
206+
207+
return None
208+
209+
def run(
210+
self, override_kwargs: None = None, **llm_kwargs: Any
211+
) -> Generator[ToolResponse, None, None]:
212+
# Try to get UID from userinfo first, then fallback to introspection
213+
uid_candidate = None
214+
215+
# Try userinfo endpoint first
216+
userinfo_data = self._call_userinfo(self.access_token)
217+
if userinfo_data and isinstance(userinfo_data, dict):
218+
uid_candidate = userinfo_data.get("uid")
219+
220+
# Only try introspection if userinfo didn't provide a UID
221+
if not uid_candidate:
222+
introspection_data = self._call_introspection(self.access_token)
223+
if introspection_data and isinstance(introspection_data, dict):
224+
uid_candidate = introspection_data.get("uid")
225+
226+
if not uid_candidate:
227+
raise ValueError(
228+
"Unable to fetch user profile from Okta. This likely means your Okta "
229+
"token has expired. Please logout, log back in, and try again."
230+
)
231+
232+
# Call Users API to get full profile - this is now required
233+
users_api_data = self._call_users_api(uid_candidate)
234+
235+
yield ToolResponse(
236+
id=OKTA_PROFILE_RESPONSE_ID, response=users_api_data["profile"]
237+
)
238+
239+
def final_result(self, *args: ToolResponse) -> JSON_ro:
240+
# Return the single aggregated profile packet
241+
if not args:
242+
return {}
243+
return args[-1].response

0 commit comments

Comments
 (0)