Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/okta_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,20 @@
from mcp.server.fastmcp import FastMCP

from okta_mcp_server.utils.auth.auth_manager import OktaAuthManager
from okta_mcp_server.utils.okta_compat import apply_okta_model_compat
from okta_mcp_server.utils.scope_guard import get_disabled_tools, get_startup_scopes, prune_tools_by_scope
from okta_mcp_server.utils.serialization import json_response

# Relax the over-strict generated Okta SDK models that reject valid API
# responses. Applied at import time so it is guaranteed to run before any SDK
# deserialization: every tool module reaches the SDK only after doing
# ``from okta_mcp_server.server import mcp``, which executes this module
# top-to-bottom first.
#
# See okta_mcp_server.utils.okta_compat for the rationale, the exact fields
# touched, and the upstream issue that would make each patch removable.
apply_okta_model_compat()

LOG_FILE = os.environ.get("OKTA_LOG_FILE")


Expand Down
30 changes: 14 additions & 16 deletions src/okta_mcp_server/tools/system_logs/system_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,24 @@

from okta_mcp_server.server import mcp
from okta_mcp_server.utils.client import get_okta_client
from okta_mcp_server.utils.okta_compat import apply_okta_model_compat
from okta_mcp_server.utils.pagination import build_query_params, create_paginated_response, extract_after_cursor, paginate_all_results
from okta_mcp_server.utils.scope_guard import require_scopes
from okta_mcp_server.utils.serialization import json_response

# Workaround for SDK v3.1.0 bug: when Behavior Detection is enabled the Okta API returns
# `userBehaviors` as List[dict], but LogSecurityContext expects List[StrictStr], which
# causes a ValidationError that crashes every get_logs call on sign-on/DENY events.
# Fix: relax the annotation to Optional[List[Any]] and force a Pydantic schema rebuild.
try:
import typing as _typing
from okta.models.log_security_context import LogSecurityContext as _LogSecurityContext

_patched_type = _typing.Optional[_typing.List[_typing.Any]]
_LogSecurityContext.__annotations__["user_behaviors"] = _patched_type
if "user_behaviors" in _LogSecurityContext.model_fields:
_LogSecurityContext.model_fields["user_behaviors"].annotation = _patched_type
_LogSecurityContext.model_rebuild(force=True)
logger.debug("Applied userBehaviors type workaround for LogSecurityContext (SDK v3.1.0 bug)")
except Exception as _patch_err:
logger.warning(f"Could not apply userBehaviors workaround: {_patch_err}")
# The `LogSecurityContext.user_behaviors` workaround that used to live inline here
# now lives with every other Okta SDK model patch in
# `okta_mcp_server.utils.okta_compat`. It is applied by `apply_okta_model_compat()`,
# which `okta_mcp_server.server` runs at import time — i.e. before this module's
# `from okta_mcp_server.server import mcp` above returns, and therefore before any
# `get_logs` call can deserialize a response. The behavior is unchanged: when
# Behavior Detection is enabled the API returns `userBehaviors` as List[dict] while
# the generated model declares List[StrictStr], which crashed every `get_logs` call
# covering sign-on/DENY events.
#
# Belt-and-braces for direct imports of this module in tests or scripts that never
# touch `server`: applying the patch set again is idempotent and cheap.
apply_okta_model_compat()


@mcp.tool()
Expand Down
370 changes: 370 additions & 0 deletions src/okta_mcp_server/utils/okta_compat.py

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from __future__ import annotations

import json
import pathlib
from dataclasses import dataclass
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
Expand All @@ -24,6 +26,29 @@
)


# ---------------------------------------------------------------------------
# JSON fixture loading
# ---------------------------------------------------------------------------

FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures"


def load_fixture(name: str) -> dict:
"""Return the parsed contents of ``tests/fixtures/<name>``.

Every fixture is synthetic: ``example.invalid`` hostnames and obviously-fake
Okta IDs only. A fresh ``dict`` is returned on each call, so a test may
mutate the result without affecting any other test.
"""
return json.loads((FIXTURES_DIR / name).read_text())


@pytest.fixture()
def fixture_loader():
"""Expose :func:`load_fixture` as a pytest fixture."""
return load_fixture


# ---------------------------------------------------------------------------
# Fake Okta auth / lifespan context
# ---------------------------------------------------------------------------
Expand Down
22 changes: 22 additions & 0 deletions tests/fixtures/access_policy_nested_embedded.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"id": "rst000000000000000001",
"name": "Example Control Access Policy",
"description": "Control fixture: `_embedded` holds only nested objects, the shape the generated model already accepted.",
"type": "ACCESS_POLICY",
"status": "ACTIVE",
"priority": 2,
"system": false,
"created": "2026-01-02T03:04:05.000Z",
"lastUpdated": "2026-01-02T03:04:05.000Z",
"_embedded": {
"mappings": {
"type": "APP",
"id": "0oa000000000000000000"
}
},
"_links": {
"self": {
"href": "https://example.invalid/api/v1/policies/rst000000000000000001"
}
}
}
30 changes: 30 additions & 0 deletions tests/fixtures/access_policy_rule_empty_user_type.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"id": "rul000000000000000001",
"name": "Example Control Access Policy Rule",
"type": "ACCESS_POLICY",
"status": "ACTIVE",
"priority": 1,
"system": false,
"created": "2026-01-02T03:04:05.000Z",
"lastUpdated": "2026-01-02T03:04:05.000Z",
"conditions": {
"people": {
"groups": {
"include": [
"00g000000000000000000"
]
}
},
"userType": {
"exclude": [],
"include": [
"otyp00000000000000000"
]
}
},
"_links": {
"self": {
"href": "https://example.invalid/api/v1/policies/rst000000000000000001/rules/rul000000000000000001"
}
}
}
28 changes: 28 additions & 0 deletions tests/fixtures/access_policy_rule_null_user_type.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"id": "rul000000000000000000",
"name": "Example Access Policy Rule",
"type": "ACCESS_POLICY",
"status": "ACTIVE",
"priority": 0,
"system": false,
"created": "2026-01-02T03:04:05.000Z",
"lastUpdated": "2026-01-02T03:04:05.000Z",
"conditions": {
"people": {
"groups": {
"include": [
"00g000000000000000000"
]
}
},
"userType": {
"exclude": null,
"include": null
}
},
"_links": {
"self": {
"href": "https://example.invalid/api/v1/policies/rst000000000000000000/rules/rul000000000000000000"
}
}
}
19 changes: 19 additions & 0 deletions tests/fixtures/access_policy_scalar_embedded.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"id": "rst000000000000000000",
"name": "Example Access Policy",
"description": "Synthetic access policy used for regression tests.",
"type": "ACCESS_POLICY",
"status": "ACTIVE",
"priority": 1,
"system": false,
"created": "2026-01-02T03:04:05.000Z",
"lastUpdated": "2026-01-02T03:04:05.000Z",
"_embedded": {
"resourceType": "APP"
},
"_links": {
"self": {
"href": "https://example.invalid/api/v1/policies/rst000000000000000000"
}
}
}
27 changes: 27 additions & 0 deletions tests/fixtures/mfa_enroll_policy_future_authenticator.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"id": "rst000000000000000003",
"name": "Example Forward Compatible Enrollment Policy",
"description": "Synthetic MFA_ENROLL policy carrying an authenticator key that does not exist yet, standing in for any future Okta authenticator type.",
"type": "MFA_ENROLL",
"status": "ACTIVE",
"priority": 2,
"system": false,
"created": "2026-01-02T03:04:05.000Z",
"lastUpdated": "2026-01-02T03:04:05.000Z",
"settings": {
"type": "AUTHENTICATORS",
"authenticators": [
{
"key": "some_future_authenticator",
"enroll": {
"self": "OPTIONAL"
}
}
]
},
"_links": {
"self": {
"href": "https://example.invalid/api/v1/policies/rst000000000000000003"
}
}
}
33 changes: 33 additions & 0 deletions tests/fixtures/mfa_enroll_policy_smart_card_idp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"id": "rst000000000000000002",
"name": "Example Enrollment Policy",
"description": "Synthetic MFA_ENROLL policy that offers a smart-card (PIV/CAC) authenticator.",
"type": "MFA_ENROLL",
"status": "ACTIVE",
"priority": 1,
"system": false,
"created": "2026-01-02T03:04:05.000Z",
"lastUpdated": "2026-01-02T03:04:05.000Z",
"settings": {
"type": "AUTHENTICATORS",
"authenticators": [
{
"key": "okta_password",
"enroll": {
"self": "REQUIRED"
}
},
{
"key": "smart_card_idp",
"enroll": {
"self": "OPTIONAL"
}
}
]
},
"_links": {
"self": {
"href": "https://example.invalid/api/v1/policies/rst000000000000000002"
}
}
}
32 changes: 32 additions & 0 deletions tests/fixtures/saml_app_signon_explicit_null_booleans.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"id": "0oa000000000000000002",
"name": "generic_saml_template",
"label": "Example App Three",
"status": "ACTIVE",
"signOnMode": "SAML_2_0",
"created": "2026-01-02T03:04:05.000Z",
"lastUpdated": "2026-01-02T03:04:05.000Z",
"settings": {
"signOn": {
"allowMultipleAcsEndpoints": null,
"assertionSigned": null,
"honorForceAuthn": null,
"requestCompressed": null,
"responseSigned": null,
"ssoAcsUrl": "https://sso.example.invalid/saml/acs",
"audience": "https://sso.example.invalid/saml/metadata",
"recipient": "https://sso.example.invalid/saml/acs",
"destination": "https://sso.example.invalid/saml/acs",
"subjectNameIdTemplate": "${user.userName}",
"subjectNameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
"signatureAlgorithm": "RSA_SHA256",
"digestAlgorithm": "SHA256",
"attributeStatements": []
}
},
"_links": {
"self": {
"href": "https://example.invalid/api/v1/apps/0oa000000000000000002"
}
}
}
32 changes: 32 additions & 0 deletions tests/fixtures/saml_app_signon_fully_populated.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"id": "0oa000000000000000003",
"name": "generic_saml_template",
"label": "Example Control App",
"status": "ACTIVE",
"signOnMode": "SAML_2_0",
"created": "2026-01-02T03:04:05.000Z",
"lastUpdated": "2026-01-02T03:04:05.000Z",
"settings": {
"signOn": {
"allowMultipleAcsEndpoints": false,
"assertionSigned": true,
"honorForceAuthn": true,
"requestCompressed": false,
"responseSigned": true,
"ssoAcsUrl": "https://sso.example.invalid/saml/acs",
"audience": "https://sso.example.invalid/saml/metadata",
"recipient": "https://sso.example.invalid/saml/acs",
"destination": "https://sso.example.invalid/saml/acs",
"subjectNameIdTemplate": "${user.userName}",
"subjectNameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
"signatureAlgorithm": "RSA_SHA256",
"digestAlgorithm": "SHA256",
"attributeStatements": []
}
},
"_links": {
"self": {
"href": "https://example.invalid/api/v1/apps/0oa000000000000000003"
}
}
}
27 changes: 27 additions & 0 deletions tests/fixtures/saml_app_signon_missing_all_five_booleans.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"id": "0oa000000000000000001",
"name": "generic_saml_template",
"label": "Example App Two",
"status": "ACTIVE",
"signOnMode": "SAML_2_0",
"created": "2026-01-02T03:04:05.000Z",
"lastUpdated": "2026-01-02T03:04:05.000Z",
"settings": {
"signOn": {
"ssoAcsUrl": "https://sso.example.invalid/saml/acs",
"audience": "https://sso.example.invalid/saml/metadata",
"recipient": "https://sso.example.invalid/saml/acs",
"destination": "https://sso.example.invalid/saml/acs",
"subjectNameIdTemplate": "${user.userName}",
"subjectNameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
"signatureAlgorithm": "RSA_SHA256",
"digestAlgorithm": "SHA256",
"attributeStatements": []
}
},
"_links": {
"self": {
"href": "https://example.invalid/api/v1/apps/0oa000000000000000001"
}
}
}
28 changes: 28 additions & 0 deletions tests/fixtures/saml_app_signon_missing_four_booleans.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"id": "0oa000000000000000000",
"name": "generic_saml_template",
"label": "Example App",
"status": "ACTIVE",
"signOnMode": "SAML_2_0",
"created": "2026-01-02T03:04:05.000Z",
"lastUpdated": "2026-01-02T03:04:05.000Z",
"settings": {
"signOn": {
"honorForceAuthn": false,
"ssoAcsUrl": "https://sso.example.invalid/saml/acs",
"audience": "https://sso.example.invalid/saml/metadata",
"recipient": "https://sso.example.invalid/saml/acs",
"destination": "https://sso.example.invalid/saml/acs",
"subjectNameIdTemplate": "${user.userName}",
"subjectNameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
"signatureAlgorithm": "RSA_SHA256",
"digestAlgorithm": "SHA256",
"attributeStatements": []
}
},
"_links": {
"self": {
"href": "https://example.invalid/api/v1/apps/0oa000000000000000000"
}
}
}
Loading