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
34 changes: 34 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
FROM python:3.13-slim

# Required by PyCharm Gateway to download the remote IDE backend
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl git ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# Add corporate root CA if present (needed when behind SSL inspection proxy)
COPY --chown=root:root .devcontainer/corporate-ca.crt* /usr/local/share/ca-certificates/
RUN update-ca-certificates

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app

# Copy dependency files first (for better caching)
COPY pyproject.toml uv.lock ./
COPY src ./src
COPY README.md ./

# Install all deps including dev, plus debugpy for PyCharm remote debugging
RUN uv sync && uv pip install debugpy

# Create non-root user
RUN useradd --create-home --shell /bin/bash appuser \
&& chown -R appuser:appuser /app \
&& mkdir -p /home/appuser/.local/share/python_keyring \
&& chown -R appuser:appuser /home/appuser/.local
USER appuser

ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENV PYTHON_KEYRING_BACKEND=keyrings.alt.file.PlaintextKeyring
26 changes: 26 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "Okta MCP Server",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"remoteUser": "appuser",
"remoteEnv": {
"PYTHON_KEYRING_BACKEND": "keyrings.alt.file.PlaintextKeyring",
"PYTHONUNBUFFERED": "1",
"OKTA_MCP_TRANSPORT": "sse"
},
"mounts": [
"source=okta-keyring,target=/home/appuser/.local/share/python_keyring,type=volume",
"source=${localWorkspaceFolder}/logs,target=/app/logs,type=bind"
],
"forwardPorts": [8000],
"customizations": {
"jetbrains": {
"backend": "PyCharm",
"projectSettings": {
"pythonInterpreterPath": "/app/.venv/bin/python"
}
}
}
}
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,16 @@ venv.bak/
# Ruff stuff:
.ruff_cache/

# Corporate CA cert (machine-specific, do not commit)
.devcontainer/corporate-ca.crt

# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
/.claude/settings.local.json

# OS
.DS_Store
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Changelog
All notable changes to this project will be documented in this file.

## April, 2026

- Refactor auth_manager.py to use authlib for OAuth 2.0 flows — [details](doc/changes/20260424-authlib-refactor.md)

## v1.0.0

- Initial release of the self hosted okta-mcp-server.
108 changes: 108 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

`uv` is at `{home}/.local/bin/uv` (not on PATH in non-interactive shells — always use the full path).

```bash
# Install dependencies
{home}/.local/bin/uv sync

# Run all tests
{home}/.local/bin/uv run pytest

# Run a single test file
{home}/.local/bin/uv run pytest tests/test_auth_manager.py -v

# Run a single test by name
{home}/.local/bin/uv run pytest tests/test_auth_manager.py::TestTokenRefresh::test_refresh_success -v

# Lint
{home}/.local/bin/uv run ruff check .

# Format / auto-fix
{home}/.local/bin/uv run ruff check --fix . && {home}/.local/bin/uv run ruff format .

# Run the server (requires env vars)
{home}/.local/bin/uv run okta-mcp-server
```

## Architecture

This is a **FastMCP server** that exposes Okta Admin Management API operations as MCP tools, allowing LLM agents to manage an Okta organization.

### Request lifecycle

```
MCP Client → FastMCP (server.py) → tool function (tools/**/*.py)
get_okta_client() ← validates/refreshes token
Okta SDK (okta library)
```

On startup, `server.py` runs `okta_authorisation_flow` (an async context manager used as the FastMCP lifespan). It creates `OktaAuthManager`, authenticates, and yields `OktaAppContext` — which is available in every tool via `ctx.request_context.lifespan_context.okta_auth_manager`.

### Tool registration

Tools are registered by importing their modules inside `main()` in `server.py`. Each module calls `@mcp.tool()` decorators at import time, which registers the function with the shared `mcp` instance (a module-level singleton in `server.py`). New tools must be imported in `main()` to be registered.

### Auth (`utils/auth/auth_manager.py`)

`OktaAuthManager` supports three flows:
- **Device Authorization Grant** (default): interactive, browser-based; tokens stored in OS keyring
- **Private Key JWT** (`OKTA_PRIVATE_KEY` + `OKTA_KEY_ID` set): browserless, no refresh token
- **XAA / RFC 8693 Token Exchange** (planned): enterprise cross-app access via identity assertion JWT

Tokens are stored in the OS keyring under service name `"OktaAuthManager"` with keys `"api_token"` and `"refresh_token"`. Docker requires `PYTHON_KEYRING_BACKEND=keyrings.alt.file.PlaintextKeyring` and a persistent volume.

### Destructive operations and elicitation

All delete/deactivate tools call `elicit_or_fallback()` from `utils/elicitation.py` before executing. This uses `ctx.elicit()` to show a structured confirmation dialog. If the client doesn't support elicitation, it returns a `fallback_response` dict that the LLM relays to the user (legacy two-step flow). Schemas are `DeleteConfirmation` and `DeactivateConfirmation` (both have a single `confirm: bool` field).

### Input validation

The `@validate_ids()` decorator in `utils/validation.py` wraps tool functions and rejects IDs containing path traversal sequences (`../`, `\`, `%2f`, etc.) or query/fragment injection (`?`, `#`). Apply it to any tool that accepts a user-supplied Okta ID.

### Pagination

`utils/pagination.py` provides `build_query_params()`, `paginate_all_results()`, and `create_paginated_response()`. Tools expose `after` (cursor), `limit` (20–100), and `fetch_all` parameters. Responses include `has_more`, `next_cursor`, and `total_fetched`.

## Testing patterns

pytest-asyncio is configured in strict mode. Async test functions need `@pytest.mark.asyncio`.

`conftest.py` provides:
- `FakeOktaAuthManager` / `FakeLifespanContext` — lightweight stubs; no real auth
- `ctx_elicit_accept_true/false`, `ctx_elicit_decline`, `ctx_elicit_cancel`, `ctx_no_elicitation`, `ctx_elicit_exception`, `ctx_elicit_mcp_error_method_not_found`, `ctx_elicit_mcp_error_other` — pre-built context mocks for elicitation scenarios
- `mock_okta_client` — `AsyncMock` with all destructive Okta SDK methods pre-configured to return `(None, None)`

Patch targets use the module namespace, e.g.:
```python
patch("okta_mcp_server.utils.auth.auth_manager.requests")
patch("okta_mcp_server.utils.auth.auth_manager.keyring")
patch("okta_mcp_server.utils.auth.auth_manager.jwt")
```

## Linting

Ruff (`.ruff.toml`): line length 119, double quotes, 4-space indent. Rules: `F` (Pyflakes), `E` (pycodestyle), `I` (isort), `RUF`. CI enforces this via `.github/workflows/ruff-check.yml`.

## Environment variables

| Variable | Required | Description |
|---|---|---|
| `OKTA_ORG_URL` | yes | Okta org URL (https:// prefix added if missing) |
| `OKTA_CLIENT_ID` | yes | OAuth2 client ID |
| `OKTA_SCOPES` | yes | Space-separated API scopes |
| `OKTA_PRIVATE_KEY` | no | PEM private key — enables browserless auth |
| `OKTA_KEY_ID` | no | Key ID (KID) for the private key |
| `OKTA_LOG_LEVEL` | no | Log level, default `INFO` |
| `OKTA_LOG_FILE` | no | Path for rotating log file (5-day retention) |
| `PYTHON_KEYRING_BACKEND` | Docker only | Set to `keyrings.alt.file.PlaintextKeyring` |

## Feature and Bug Fix Implementation
- every feature and bug fix must be documented in the ./doc/changes/ directory with the following constraints: 1. File format DATATIME-<short feature description>.md 2. The file must contain the following sections: a. Purpose [of the change] b. Impact [to the existing functionality] c. Implementation [overview] d. Test [overview, including code coverage for files with new changes]
- update CHANGELOG.md with a short feature description and a link to the appropriate file in ./doc/changes/ . Unless otherwise specified, put the updates under a date heading in the format of ## <Month name>, <Year>
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ RUN uv sync --no-dev

# Create non-root user for security
RUN useradd --create-home --shell /bin/bash appuser \
&& chown -R appuser:appuser /app
&& chown -R appuser:appuser /app \
&& mkdir -p /home/appuser/.local/share/python_keyring \
&& chown -R appuser:appuser /home/appuser/.local
USER appuser

# Set environment variables
Expand Down
106 changes: 106 additions & 0 deletions doc/changes/20260424-authlib-refactor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Authlib Refactor & DevContainer Support

## Purpose

Replace hand-rolled OAuth 2.0 implementations in `OktaAuthManager` with
authlib primitives. This is tech-debt cleanup that unblocks adding the ID-JAG
grant for Cross-App Access (XAA) without further custom OAuth plumbing. The
full refactor plan is documented in `doc/AUTHLIB_REFACTOR_PLAN.md`.

Additionally, add a devcontainer configuration and fix Docker keyring volume
ownership so the project can be developed and debugged inside containers
(PyCharm Gateway / VS Code Remote).

## Impact

- **Public API unchanged.** `OktaAuthManager.__init__`, `authenticate`,
`is_valid_token`, `refresh_access_token`, and `clear_tokens` keep identical
signatures. Env-var and keyring contracts are preserved.
- **Dependency change:** `authlib>=1.3.0` added; `pyjwt` is no longer
imported (authlib handles JWT signing internally via `cryptography`).
- **Browserless flow:** `_get_client_assertion` removed; replaced by authlib
`OAuth2Session` with `PrivateKeyJWT` client auth method.
- **Token refresh:** `refresh_access_token` now uses `OAuth2Session.refresh_token`
instead of a manual `requests.post`.
- **Device flow polling:** Added handling for two previously missing RFC 8628
error codes: `slow_down` (backs off interval) and `expired_token` (aborts).
- **Docker:** Keyring volume now mounts to `/home/appuser/` (not `/root/`),
matching the non-root user. Dockerfile creates the keyring directory with
correct ownership. `docker-compose.yml` declares the named volume and sets
the image name.
- **DevContainer:** New `.devcontainer/` with Dockerfile and
`devcontainer.json` for containerised development with PyCharm or VS Code.

## Implementation

### auth_manager.py

- Removed `_get_client_assertion` (manual JWT minting via `pyjwt`).
- `_browserless_authenticate` now creates an `OAuth2Session` configured with
`token_endpoint_auth_method="private_key_jwt"`, registers a `PrivateKeyJWT`
instance with the `kid` header, and calls `session.fetch_token`.
- `refresh_access_token` now creates an `OAuth2Session` with
`token_endpoint_auth_method="none"` and calls `session.refresh_token`.
- `_poll_for_token` now handles `slow_down` (exponential back-off on the
polling interval) and `expired_token` (returns `None`).
- Exception handling simplified: broad `except Exception` catches authlib and
network errors in both flows, logged with a single message.

### pyproject.toml

- Added `authlib>=1.3.0` to dependencies.
- Added `pytest-cov>=6.0.0` to dev dependencies.
- Added `[tool.pytest.ini_options]` with `asyncio_mode = "strict"` and a
filter to suppress `AuthlibDeprecationWarning`.
- Added `[tool.coverage.run]` and `[tool.coverage.report]` configuration.

### Docker / DevContainer

- `Dockerfile`: creates `/home/appuser/.local/share/python_keyring` with
correct ownership.
- `docker-compose.yml`: keyring volume target changed from `/root/` to
`/home/appuser/`, image name set, top-level `volumes:` block added.
- `.devcontainer/Dockerfile`: Python 3.13-slim image with `uv`, optional
corporate CA cert, `debugpy`, non-root user.
- `.devcontainer/devcontainer.json`: PyCharm Gateway settings, keyring
volume mount, port forwarding.

### .gitignore

- Added `.devcontainer/corporate-ca.crt` (machine-specific cert).
- Added `/.claude/settings.local.json`.

## Test

New test file `tests/test_auth_manager.py` (759 lines) providing comprehensive
unit-test coverage for `OktaAuthManager`:

- **TestInit:** env-var validation, `https://` prefix normalisation, default
and custom scope merging, browserless flag detection.
- **TestBrowserlessAuth:** authlib `OAuth2Session` + `PrivateKeyJWT` wiring,
success path (token stored in keyring), missing-token response, exception
handling.
- **TestDeviceFlowAuth:** device authorisation initiation, successful token
polling, `authorization_pending` retry, `slow_down` back-off,
`access_denied` and `expired_token` terminal errors, HTTP failure, browser
open and user code display.
- **TestTokenRefresh:** successful refresh with new refresh token rotation,
refresh without rotation, missing refresh token, failure handling.
- **TestIsValidToken:** token within / beyond expiry window, fresh token,
keyring miss (no token stored).
- **TestClearTokens:** keyring deletion of both `api_token` and
`refresh_token`.
- **TestAuthenticate:** integration-level async tests for device-flow and
browserless dispatch, plus failure-to-authenticate path.

All external I/O is mocked (`requests`, `keyring`, `OAuth2Session`,
`PrivateKeyJWT`, `webbrowser`, `time`).

### Code Coverage

| File | Stmts | Miss | Branch | BrPart | Cover |
|---|---|---|---|---|---|
| `src/okta_mcp_server/utils/auth/auth_manager.py` | 205 | 6 | 46 | 6 | **95%** |

43 tests, all passing. Uncovered lines: 88, 202-204, 252, 278 (edge-case
error paths in device-flow initiation and re-authentication fallback).
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
services:
okta-mcp-server:
image: okta-mcp-server
build:
context: .
dockerfile: Dockerfile
Expand Down Expand Up @@ -29,5 +30,8 @@ services:
# restart: "no"

volumes:
- okta-keyring:/root/.local/share/python_keyring
- okta-keyring:/home/appuser/.local/share/python_keyring
- ./logs:/app/logs

volumes:
okta-keyring:
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies = [
"keyring>=25.6.0",
"keyrings.alt>=5.0.0",
"flatdict>=4.1.0",
"authlib>=1.3.0",
]

[build-system]
Expand All @@ -26,7 +27,22 @@ packages = ["src/okta_mcp_server"]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"pytest-cov>=6.0.0",
]

[tool.pytest.ini_options]
asyncio_mode = "strict"
filterwarnings = [
"ignore::authlib.deprecate.AuthlibDeprecationWarning",
]

[tool.coverage.run]
source = ["src/okta_mcp_server"]
omit = ["tests/*"]
branch = true

[tool.coverage.report]
show_missing = true

[project.scripts]
okta-mcp-server = "okta_mcp_server:main"
Loading