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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ OKTA_ORG_URL=
# Required: OAuth Client ID from your Okta application
OKTA_CLIENT_ID=

# Required (HTTP mode only): OAuth Client Secret from your Okta application
# OKTA_CLIENT_SECRET=

# Required: API Scopes (space-separated, e.g., "okta.users.read okta.groups.manage")
OKTA_SCOPES=okta.users.read okta.groups.read

Expand All @@ -15,6 +18,13 @@ OKTA_SCOPES=okta.users.read okta.groups.read
# OKTA_PRIVATE_KEY=
# OKTA_KEY_ID=

# Optional: Transport mode ("stdio" for CLI, "streamable-http" for remote/Docker)
# MCP_TRANSPORT=streamable-http

# Optional: Public URL of the MCP server (required when MCP_TRANSPORT=streamable-http)
# Used for OAuth redirect URIs. Must match the Okta app's redirect URI setting.
# MCP_SERVER_URL=https://mcp.example.com

# Optional: Logging configuration
# OKTA_LOG_LEVEL=DEBUG
# OKTA_LOG_FILE=/app/logs/okta-mcp.log
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
# Use file-based keyring backend for Docker (no system keyring available)
ENV PYTHON_KEYRING_BACKEND=keyrings.alt.file.PlaintextKeyring
# Use streamable HTTP transport in Docker
ENV MCP_TRANSPORT=streamable-http

EXPOSE 8000

# Run the server using the console script entry point
ENTRYPOINT ["okta-mcp-server"]
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"loguru>=0.7.3",
"mcp[cli]>=1.26.0,<2.0.0",
"fastmcp>=3.0.0",
"okta>=2.9.13",
"requests>=2.32.4",
"ruff>=0.11.13",
"keyring>=25.6.0",
"keyrings.alt>=5.0.0",
"flatdict>=4.1.0",
"httpx>=0.27.0",
]

[build-system]
Expand Down
106 changes: 89 additions & 17 deletions src/okta_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,113 @@
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and limitations under the License.

from __future__ import annotations

import os
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass

from fastmcp import FastMCP
from loguru import logger
from mcp.server.fastmcp import FastMCP

from okta_mcp_server.utils.auth.auth_manager import OktaAuthManager

LOG_FILE = os.environ.get("OKTA_LOG_FILE")
MCP_TRANSPORT = os.environ.get("MCP_TRANSPORT", "stdio")


@dataclass
class OktaAppContext:
okta_auth_manager: OktaAuthManager
okta_auth_manager: OktaAuthManager | None = None


@asynccontextmanager
async def okta_authorisation_flow(server: FastMCP) -> AsyncIterator[OktaAppContext]:
"""
Manages the application lifecycle. It initializes the OktaManager on startup,
performs authorization, and yields the context for use in tools.
Manages the application lifecycle. In stdio mode, initializes OktaAuthManager
for device/JWT flow. In HTTP mode with OAuthProxy, authentication is handled
via browser redirect — no OktaAuthManager needed.
"""
logger.info("Starting Okta authorization flow")
manager = OktaAuthManager()
await manager.authenticate()
logger.info("Okta authentication completed successfully")

try:
yield OktaAppContext(okta_auth_manager=manager)
finally:
logger.debug("Clearing Okta tokens")
manager.clear_tokens()

if MCP_TRANSPORT == "streamable-http":
logger.info("HTTP transport: OAuthProxy handles authentication via browser redirect")
yield OktaAppContext()
else:
logger.info("Initializing OktaAuthManager (authentication deferred to first tool call)")
manager = OktaAuthManager()
try:
yield OktaAppContext(okta_auth_manager=manager)
finally:
logger.debug("Clearing Okta tokens")
manager.clear_tokens()


# --- Build the FastMCP instance based on transport mode ---

if MCP_TRANSPORT == "streamable-http":
import httpx
from fastmcp.server.auth import AccessToken, OAuthProxy, TokenVerifier

class OktaIntrospectionVerifier(TokenVerifier):
"""Validates opaque Okta tokens via the introspection endpoint."""

def __init__(self, introspect_url: str, client_id: str, client_secret: str):
super().__init__()
self._introspect_url = introspect_url
self._client_id = client_id
self._client_secret = client_secret

async def verify_token(self, token: str) -> AccessToken | None:
async with httpx.AsyncClient() as client:
resp = await client.post(
self._introspect_url,
data={"token": token, "token_type_hint": "access_token"},
auth=(self._client_id, self._client_secret),
)
if resp.status_code != 200:
return None
data = resp.json()
if not data.get("active"):
return None
return AccessToken(
token=token,
client_id=data.get("client_id", self._client_id),
scopes=data.get("scope", "").split(),
expires_at=data.get("exp"),
)

_mcp_server_url = os.environ.get("MCP_SERVER_URL", "http://localhost:8000")
_okta_org_url = os.environ.get("OKTA_ORG_URL", "").rstrip("/")
_okta_client_id = os.environ.get("OKTA_CLIENT_ID", "")
_okta_client_secret = os.environ.get("OKTA_CLIENT_SECRET", "")
_okta_scopes = os.environ.get("OKTA_SCOPES", "openid profile email offline_access")

_auth = OAuthProxy(
upstream_authorization_endpoint=f"{_okta_org_url}/oauth2/v1/authorize",
upstream_token_endpoint=f"{_okta_org_url}/oauth2/v1/token",
upstream_client_id=_okta_client_id,
upstream_client_secret=_okta_client_secret,
token_verifier=OktaIntrospectionVerifier(
introspect_url=f"{_okta_org_url}/oauth2/v1/introspect",
client_id=_okta_client_id,
client_secret=_okta_client_secret,
),
base_url=_mcp_server_url,
require_authorization_consent=False,
extra_authorize_params={"scope": _okta_scopes},
)

mcp = FastMCP("Okta IDaaS MCP Server", lifespan=okta_authorisation_flow)
mcp = FastMCP(
"Okta IDaaS MCP Server",
lifespan=okta_authorisation_flow,
auth=_auth,
)
else:
mcp = FastMCP(
"Okta IDaaS MCP Server",
lifespan=okta_authorisation_flow,
)


def main():
Expand Down Expand Up @@ -70,4 +139,7 @@ def main():
from okta_mcp_server.tools.system_logs import system_logs # noqa: F401
from okta_mcp_server.tools.users import users # noqa: F401

mcp.run()
if MCP_TRANSPORT == "streamable-http":
mcp.run(transport=MCP_TRANSPORT, host="0.0.0.0", port=8000)
else:
mcp.run(transport=MCP_TRANSPORT)
43 changes: 35 additions & 8 deletions src/okta_mcp_server/utils/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,53 @@
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and limitations under the License.

from __future__ import annotations

import os

import keyring
from loguru import logger
from okta.client import Client as OktaClient

from okta_mcp_server.utils.auth.auth_manager import SERVICE_NAME, OktaAuthManager


async def get_okta_client(manager: OktaAuthManager) -> OktaClient:
"""Initialize and return an Okta client"""
async def get_okta_client(manager: OktaAuthManager | None) -> OktaClient:
"""Initialize and return an Okta client.

In stdio mode (manager is not None): uses OktaAuthManager + keyring.
In HTTP mode (manager is None): gets Okta token from MCP Bearer auth context.
"""
logger.debug("Initializing Okta client")
api_token = keyring.get_password(SERVICE_NAME, "api_token")
if not await manager.is_valid_token():
logger.warning("Token is invalid or expired, re-authenticating")
await manager.authenticate()

if manager is not None:
# stdio mode — existing behavior
api_token = keyring.get_password(SERVICE_NAME, "api_token")
if not await manager.is_valid_token():
logger.warning("Token is invalid or expired, re-authenticating")
await manager.authenticate()
api_token = keyring.get_password(SERVICE_NAME, "api_token")
org_url = manager.org_url
else:
# HTTP mode — OAuthProxy passes through the upstream Okta access token.
# The Bearer token in the request IS the Okta access token.
from mcp.server.auth.middleware.auth_context import get_access_token

access_token_info = get_access_token()
if access_token_info is None:
raise RuntimeError("No authenticated user in HTTP mode")

api_token = access_token_info.token
if not api_token:
raise RuntimeError("No Okta access token in auth context")

org_url = os.environ.get("OKTA_ORG_URL", "")

config = {
"orgUrl": manager.org_url,
"orgUrl": org_url,
"token": api_token,
"authorizationMode": "Bearer",
"userAgent": "okta-mcp-server/0.0.1",
}
logger.debug(f"Okta client configured for org: {manager.org_url}")
logger.debug(f"Okta client configured for org: {org_url}")
return OktaClient(config)