diff --git a/.env.example b/.env.example index 6770e88..b8d640a 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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 diff --git a/Dockerfile b/Dockerfile index f15451f..12ab8cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/pyproject.toml b/pyproject.toml index 0321c93..4aa5f37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/okta_mcp_server/server.py b/src/okta_mcp_server/server.py index caa81ba..cd438ec 100644 --- a/src/okta_mcp_server/server.py +++ b/src/okta_mcp_server/server.py @@ -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(): @@ -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) diff --git a/src/okta_mcp_server/utils/client.py b/src/okta_mcp_server/utils/client.py index dea45ed..384b169 100644 --- a/src/okta_mcp_server/utils/client.py +++ b/src/okta_mcp_server/utils/client.py @@ -5,6 +5,10 @@ # 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 @@ -12,19 +16,42 @@ 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)