From f6efdd163b6ef03bee94fbcd21cdcafc5f6e6778 Mon Sep 17 00:00:00 2001 From: Maximilian von Heyden Date: Thu, 13 Aug 2026 09:08:26 +0200 Subject: [PATCH 1/6] feat(client): create and list personal API tokens Core Kimai can only DELETE an access token; creating one is a web-form action in ProfileController, so there is no API for it. The ApiTokenBundle plugin added in the following commit supplies POST/GET /api/users/{id}/api-token, and these two methods are its client side. create_api_token() returns the token value, which Kimai renders exactly once - hence the dedicated model rather than a raw dict. Co-Authored-By: Claude Opus 5 (1M context) --- src/kimai_mcp/client.py | 38 ++++++++++++++- src/kimai_mcp/models.py | 19 ++++++++ tests/test_api_tokens.py | 100 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 tests/test_api_tokens.py diff --git a/src/kimai_mcp/client.py b/src/kimai_mcp/client.py index 3f0e0d3..cbfa325 100644 --- a/src/kimai_mcp/client.py +++ b/src/kimai_mcp/client.py @@ -10,6 +10,8 @@ Absence, AbsenceFilter, AbsenceForm, + AccessTokenCreated, + AccessTokenInfo, Activity, ActivityEditForm, ActivityExtended, @@ -954,7 +956,41 @@ async def update_user_preferences( async def delete_api_token(self, token_id: int) -> dict[str, Any]: """Delete an API token (only own tokens).""" return await self._request("DELETE", f"/users/api-token/{token_id}") - + + async def create_api_token( + self, + user_id: int, + name: str, + expires_at: str | None = None, + replace_existing: bool = True, + ) -> AccessTokenCreated: + """Create an API token for a user and return it (shown only once). + + Requires the ``ApiTokenBundle`` plugin (``kimai-plugin/ApiTokenBundle``) + on the Kimai server; core Kimai can only create tokens through its web + UI. The calling token must belong to a user holding + ``api-token_other_profile`` (ROLE_SUPER_ADMIN by default) unless it is + creating a token for its own account. + + Raises: + KimaiAPIError: 404 if the plugin is not installed, 403 if the + calling token lacks the permission. + """ + payload: dict[str, Any] = {"name": name, "replaceExisting": replace_existing} + if expires_at: + payload["expiresAt"] = expires_at + data = await self._request("POST", f"/users/{user_id}/api-token", json=payload) + return AccessTokenCreated(**data) + + async def get_api_tokens(self, user_id: int) -> list[AccessTokenInfo]: + """List a user's API token metadata (never the token value). + + Requires the ``ApiTokenBundle`` plugin, see :meth:`create_api_token`. + """ + data = await self._request("GET", f"/users/{user_id}/api-token") + return [AccessTokenInfo(**item) for item in data] + + # Invoice endpoints async def get_invoices(self, filters: InvoiceFilter | None = None) -> list[Invoice]: diff --git a/src/kimai_mcp/models.py b/src/kimai_mcp/models.py index b4ded0c..5cdcc10 100644 --- a/src/kimai_mcp/models.py +++ b/src/kimai_mcp/models.py @@ -21,6 +21,25 @@ class KimaiModel(BaseModel): model_config = ConfigDict(populate_by_name=True) +class AccessTokenInfo(KimaiModel): + """Metadata of a personal API token. + + Served by the ``ApiTokenBundle`` plugin (see ``kimai-plugin/ApiTokenBundle``); + core Kimai has no endpoint that lists or creates access tokens. + """ + + id: int + name: str | None = None + last_usage: str | None = Field(None, alias="lastUsage") + expires_at: str | None = Field(None, alias="expiresAt") + + +class AccessTokenCreated(AccessTokenInfo): + """A freshly created API token - ``token`` is only ever returned once.""" + + token: str + + class User(KimaiModel): """User model (serializer group ``Default``). diff --git a/tests/test_api_tokens.py b/tests/test_api_tokens.py new file mode 100644 index 0000000..e151d51 --- /dev/null +++ b/tests/test_api_tokens.py @@ -0,0 +1,100 @@ +"""Tests for the personal API token endpoints of the ApiTokenBundle plugin. + +The token value is the whole point of ``create_api_token``: Kimai returns it +exactly once, so a parsing slip here does not degrade gracefully, it loses a +credential that cannot be fetched again. +""" + +import json + +import pytest + +from kimai_mcp.client import KimaiAPIError, KimaiClient + +BASE_URL = "https://kimai.example.com" +TOKEN_URL = f"{BASE_URL}/api/users/7/api-token" + + +def make_client() -> KimaiClient: + return KimaiClient(BASE_URL, "admin-token") + + +@pytest.mark.asyncio +async def test_create_api_token_returns_the_token(httpx_mock): + httpx_mock.add_response( + url=TOKEN_URL, + method="POST", + status_code=201, + json={ + "id": 42, + "name": "Kimai MCP (auto)", + "token": "abc123", + "lastUsage": None, + "expiresAt": None, + }, + ) + + async with make_client() as client: + created = await client.create_api_token(user_id=7, name="Kimai MCP (auto)") + + assert created.token == "abc123" + assert created.id == 42 + assert created.name == "Kimai MCP (auto)" + assert created.expires_at is None + + +@pytest.mark.asyncio +async def test_create_api_token_sends_the_expected_payload(httpx_mock): + httpx_mock.add_response( + url=TOKEN_URL, method="POST", status_code=201, json={"id": 1, "token": "t"} + ) + + async with make_client() as client: + await client.create_api_token(user_id=7, name="ci", expires_at="2027-01-01") + + payload = json.loads(httpx_mock.get_requests()[0].content) + assert payload == {"name": "ci", "replaceExisting": True, "expiresAt": "2027-01-01"} + + +@pytest.mark.asyncio +async def test_create_api_token_omits_an_unset_expiry(httpx_mock): + httpx_mock.add_response( + url=TOKEN_URL, method="POST", status_code=201, json={"id": 1, "token": "t"} + ) + + async with make_client() as client: + await client.create_api_token(user_id=7, name="ci", replace_existing=False) + + payload = json.loads(httpx_mock.get_requests()[0].content) + assert payload == {"name": "ci", "replaceExisting": False} + + +@pytest.mark.asyncio +async def test_create_api_token_without_the_plugin_raises_404(httpx_mock): + httpx_mock.add_response(url=TOKEN_URL, method="POST", status_code=404, json={}) + + async with make_client() as client: + with pytest.raises(KimaiAPIError) as excinfo: + await client.create_api_token(user_id=7, name="ci") + + assert excinfo.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_api_tokens_returns_metadata_only(httpx_mock): + httpx_mock.add_response( + url=TOKEN_URL, + method="GET", + json=[ + {"id": 1, "name": "laptop", "lastUsage": "2026-08-01T10:00:00+0200", "expiresAt": None}, + {"id": 2, "name": "Kimai MCP (auto)", "lastUsage": None, "expiresAt": None}, + ], + ) + + async with make_client() as client: + tokens = await client.get_api_tokens(user_id=7) + + assert [t.name for t in tokens] == ["laptop", "Kimai MCP (auto)"] + assert tokens[0].last_usage == "2026-08-01T10:00:00+0200" + # The listing endpoint must never carry the token value itself. + assert not any(hasattr(t, "token") for t in tokens) From f7d65eb523e669529b017853b58c3107c6c43054 Mon Sep 17 00:00:00 2001 From: Maximilian von Heyden Date: Thu, 13 Aug 2026 09:09:32 +0200 Subject: [PATCH 2/6] feat(plugin): ship the Kimai bundle exposing POST /api/users/{id}/api-token The endpoint the previous commit calls does not exist in Kimai. Creating an access token is a web-form action (ProfileController::createAccessToken), so the only alternative for automated onboarding would be driving an admin web session through that HTML form - CSRF token, throttling, 2FA and all. The bundle reuses Kimai's own `api-token` voter, i.e. it grants nothing the Kimai UI would not: the caller needs `api-token_other_profile`, which only ROLE_SUPER_ADMIN holds by default. Requires Kimai 2.65+. It is not part of the Python package or the Docker image; it is copied into the Kimai host's var/plugins/. See kimai-plugin/ApiTokenBundle/README.md, which also states plainly that this repository's CI cannot test PHP. Co-Authored-By: Claude Opus 5 (1M context) --- .dockerignore | 4 + .../ApiTokenBundle/API/ApiTokenController.php | 245 ++++++++++++++++++ .../ApiTokenBundle/ApiTokenBundle.php | 17 ++ .../DependencyInjection/ApiTokenExtension.php | 33 +++ kimai-plugin/ApiTokenBundle/README.md | 97 +++++++ .../Resources/config/routes.yaml | 4 + .../Resources/config/services.yaml | 19 ++ kimai-plugin/ApiTokenBundle/composer.json | 27 ++ 8 files changed, 446 insertions(+) create mode 100644 kimai-plugin/ApiTokenBundle/API/ApiTokenController.php create mode 100644 kimai-plugin/ApiTokenBundle/ApiTokenBundle.php create mode 100644 kimai-plugin/ApiTokenBundle/DependencyInjection/ApiTokenExtension.php create mode 100644 kimai-plugin/ApiTokenBundle/README.md create mode 100644 kimai-plugin/ApiTokenBundle/Resources/config/routes.yaml create mode 100644 kimai-plugin/ApiTokenBundle/Resources/config/services.yaml create mode 100644 kimai-plugin/ApiTokenBundle/composer.json diff --git a/.dockerignore b/.dockerignore index 8ca34b4..732d84a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -67,5 +67,9 @@ tests/ # Examples examples/ +# Kimai-side PHP plugin - deployed into the Kimai host's var/plugins/, +# never into this server's image +kimai-plugin/ + # CI/CD .github/ diff --git a/kimai-plugin/ApiTokenBundle/API/ApiTokenController.php b/kimai-plugin/ApiTokenBundle/API/ApiTokenController.php new file mode 100644 index 0000000..26a946b --- /dev/null +++ b/kimai-plugin/ApiTokenBundle/API/ApiTokenController.php @@ -0,0 +1,245 @@ + '\d+'])] + public function listApiTokens(User $profile): Response + { + $this->assertCanManageTokensOf($profile); + + $tokens = array_map( + fn (AccessToken $token) => $this->serializeToken($token), + $this->accessTokenRepository->findForUser($profile) + ); + + return new JsonResponse($tokens); + } + + /** + * Create an API token for a user and return it once + */ + #[OA\Response( + response: 201, + description: 'The created API token. The "token" value is only ever returned here. Required permission: api-token', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'token', type: 'string'), + new OA\Property(property: 'lastUsage', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'expiresAt', type: 'string', format: 'date-time', nullable: true), + ], + type: 'object' + ) + )] + #[OA\Parameter(name: 'id', description: 'User ID to create the token for', in: 'path', required: true)] + #[OA\RequestBody(content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string', description: 'Token name shown in the user profile (2-50 characters)'), + new OA\Property(property: 'expiresAt', type: 'string', format: 'date-time', nullable: true, description: 'Optional expiration date (Y-m-d or ISO 8601)'), + new OA\Property(property: 'replaceExisting', type: 'boolean', description: 'Delete the user\'s existing tokens with the same name first'), + ], + type: 'object' + ))] + #[Route(methods: ['POST'], path: '/{id}/api-token', name: 'post_api_token', requirements: ['id' => '\d+'])] + public function createApiToken(User $profile, Request $request): Response + { + $this->assertCanManageTokensOf($profile); + + $payload = $this->decodeBody($request); + $name = $this->readName($payload); + $expiresAt = $this->readExpiresAt($payload); + $replaceExisting = (bool) ($payload['replaceExisting'] ?? false); + + if ($replaceExisting) { + foreach ($this->accessTokenRepository->findForUser($profile) as $existing) { + if ($existing->getName() === $name) { + $this->accessTokenRepository->deleteAccessToken($existing); + } + } + } + + $accessToken = new AccessToken($profile, substr(bin2hex(random_bytes(100)), 0, self::TOKEN_LENGTH)); + $accessToken->setName($name); + if ($expiresAt !== null) { + $accessToken->setExpiresAt($expiresAt); + } + + $this->accessTokenRepository->saveAccessToken($accessToken); + + return new JsonResponse( + $this->serializeToken($accessToken) + ['token' => $accessToken->getToken()], + Response::HTTP_CREATED + ); + } + + /** + * The same two checks Kimai performs in UserController::deleteApiToken(): + * the caller needs token access at all, and access to this profile in + * particular (`api-token_other_profile` for anyone but themselves). + */ + private function assertCanManageTokensOf(User $profile): void + { + if (!$this->isGranted('api-token', $this->getUser())) { + throw $this->createAccessDeniedException('User has no access to API tokens'); + } + + if (!$this->isGranted('api-token', $profile)) { + throw $this->createAccessDeniedException('You are not allowed to manage the API tokens of this user'); + } + } + + /** + * @return array + */ + private function decodeBody(Request $request): array + { + $content = $request->getContent(); + if ($content === '') { + return []; + } + + try { + $payload = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new BadRequestHttpException('Invalid JSON body: ' . $e->getMessage()); + } + + if (!\is_array($payload)) { + throw new BadRequestHttpException('Request body must be a JSON object'); + } + + return $payload; + } + + /** + * @param array $payload + */ + private function readName(array $payload): string + { + $name = $payload['name'] ?? self::DEFAULT_NAME; + if (!\is_string($name)) { + throw new BadRequestHttpException('"name" must be a string'); + } + + $name = trim($name); + $length = mb_strlen($name); + if ($length < self::NAME_MIN_LENGTH || $length > self::NAME_MAX_LENGTH) { + throw new BadRequestHttpException( + \sprintf('"name" must be between %d and %d characters', self::NAME_MIN_LENGTH, self::NAME_MAX_LENGTH) + ); + } + + return $name; + } + + /** + * @param array $payload + */ + private function readExpiresAt(array $payload): ?\DateTimeImmutable + { + $raw = $payload['expiresAt'] ?? null; + if ($raw === null || $raw === '') { + return null; + } + + if (!\is_string($raw)) { + throw new BadRequestHttpException('"expiresAt" must be a date string'); + } + + try { + $date = new \DateTimeImmutable($raw); + } catch (\Exception $e) { + throw new BadRequestHttpException('"expiresAt" is not a valid date: ' . $e->getMessage()); + } + + if ($date <= new \DateTimeImmutable()) { + throw new BadRequestHttpException('"expiresAt" must be in the future'); + } + + return $date; + } + + /** + * @return array + */ + private function serializeToken(AccessToken $token): array + { + return [ + 'id' => $token->getId(), + 'name' => $token->getName(), + 'lastUsage' => $token->getLastUsage()?->format(self::DATE_FORMAT_PHP), + 'expiresAt' => $token->getExpiresAt()?->format(self::DATE_FORMAT_PHP), + ]; + } +} diff --git a/kimai-plugin/ApiTokenBundle/ApiTokenBundle.php b/kimai-plugin/ApiTokenBundle/ApiTokenBundle.php new file mode 100644 index 0000000..bd001ab --- /dev/null +++ b/kimai-plugin/ApiTokenBundle/ApiTokenBundle.php @@ -0,0 +1,17 @@ + ApiTokenExtension). + * Without it the routes still resolve, but the controller is not a service, so + * every request dies with "has required constructor arguments and does not + * exist in the container". + */ +class ApiTokenExtension extends Extension +{ + public function load(array $configs, ContainerBuilder $container): void + { + $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); + $loader->load('services.yaml'); + } +} diff --git a/kimai-plugin/ApiTokenBundle/README.md b/kimai-plugin/ApiTokenBundle/README.md new file mode 100644 index 0000000..a8bb41f --- /dev/null +++ b/kimai-plugin/ApiTokenBundle/README.md @@ -0,0 +1,97 @@ +# ApiTokenBundle (Kimai plugin) + +Adds the one REST endpoint Kimai is missing: **creating** a personal API token for a user. + +Kimai's own API can only delete tokens (`DELETE /api/users/api-token/{id}`); creating one is a +web-form action (`ProfileController::createAccessToken`, route `user_profile_access_token`). +Without this plugin, automated onboarding would have to drive an admin **web session** through +that HTML form — CSRF token, login throttling, 2FA and all — and would break on any Kimai UI +change. This bundle exposes the same operation, with the same permission check, as a normal API +endpoint. + +Used by the Kimai MCP server (`src/kimai_mcp/provisioning.py`, enabled with `--auto-provision`) to +give every user who signs in through the configured OIDC provider their own Kimai token +automatically, so nobody has to copy a token by hand. + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/users/{id}/api-token` | Token metadata of that user (`id`, `name`, `lastUsage`, `expiresAt`) — never the token value | +| `POST` | `/api/users/{id}/api-token` | Creates a token and returns it **once**, `201` | + +`POST` body (all fields optional): + +```json +{ + "name": "Kimai MCP (auto)", + "expiresAt": "2027-01-01", + "replaceExisting": true +} +``` + +* `name` — 2–50 characters, shown in the user's profile (default: `API token`). +* `expiresAt` — `Y-m-d` or ISO 8601, must be in the future; omitted means no expiry. +* `replaceExisting` — deletes that user's existing tokens **with the same name** first, so + re-provisioning does not pile up dead tokens. + +Response: + +```json +{ + "id": 42, + "name": "Kimai MCP (auto)", + "lastUsage": null, + "expiresAt": null, + "token": "0f89a9b2a4124faebfd89" +} +``` + +## Permissions + +Both endpoints require the `api-token` voter for the target profile — i.e. the calling API token +must belong to a user with `api-token_other_profile`, which in Kimai's default role mapping only +**ROLE_SUPER_ADMIN** has (`PROFILE_OTHER` in `config/packages/kimai.yaml`). Callers can always +manage their own tokens (`api-token_own_profile`). This is exactly the check +`UserController::deleteApiToken()` performs, so the plugin grants no permission that the Kimai UI +would not. + +## Installation + +```bash +# on the Kimai host, as the vhost user +cp -r ApiTokenBundle /var/plugins/ +chown -R : /var/plugins/ApiTokenBundle +php bin/console kimai:reload --env=prod +``` + +Verify: + +```bash +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name":"probe","replaceExisting":true}' \ + https:///api/users//api-token +# then confirm the token belongs to that user, and clean up: +curl -H "Authorization: Bearer " https:///api/users/me +curl -X DELETE -H "Authorization: Bearer " https:///api/users/api-token/ +``` + +The endpoints also appear in Kimai's Swagger UI (`/api/doc`) under the *User* tag. + +## Compatibility + +Requires Kimai **2.65.0** (`extra.kimai.require: 26500`) or newer. It relies on three stable +internals: the `AccessToken` entity, `AccessTokenRepository`, and the `api-token` voter attribute. +After a Kimai upgrade, re-run the verification call above — the MCP server treats a `404`/`403` +from this endpoint as "auto-provisioning unavailable" and answers the sign-in with its usual +"not authorized" page, so a broken plugin degrades onboarding rather than breaking the server. +The server also probes for this bundle at startup (`--auto-provision`) and logs an explicit error +naming this file when it is missing. + +## Test coverage + +There is none, and the CI of this repository cannot supply any: it is a Python project, so it runs +`ruff` and `pytest` and has no PHP toolchain or Kimai checkout to test a bundle against. The +Python side is covered (`tests/test_provisioning.py`, `tests/test_api_tokens.py`), the PHP side is +verified by the manual `curl` sequence above. Treat a Kimai major upgrade as a reason to re-run it. diff --git a/kimai-plugin/ApiTokenBundle/Resources/config/routes.yaml b/kimai-plugin/ApiTokenBundle/Resources/config/routes.yaml new file mode 100644 index 0000000..d87c123 --- /dev/null +++ b/kimai-plugin/ApiTokenBundle/Resources/config/routes.yaml @@ -0,0 +1,4 @@ +apitoken.api: + resource: "@ApiTokenBundle/API/" + type: attribute + prefix: /api diff --git a/kimai-plugin/ApiTokenBundle/Resources/config/services.yaml b/kimai-plugin/ApiTokenBundle/Resources/config/services.yaml new file mode 100644 index 0000000..f6fdfda --- /dev/null +++ b/kimai-plugin/ApiTokenBundle/Resources/config/services.yaml @@ -0,0 +1,19 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + # The API controller needs to be a service: autoconfigure gives it the + # controller.service_arguments tag, without which Symfony cannot inject the + # AccessTokenRepository and every request 500s. + KimaiPlugin\ApiTokenBundle\API\: + resource: '../../API/*' + + # The bundle class itself, so Kimai lists the plugin. PluginManager iterates + # services tagged PluginInterface (the interface carries AutoconfigureTag), + # so a bundle that is not a service works but stays invisible in /api/plugins + # and the admin UI. Registered explicitly rather than through a namespace + # glob: a glob is evaluated at compile time and can abort a Kimai update + # mid-`composer install`. + KimaiPlugin\ApiTokenBundle\ApiTokenBundle: ~ diff --git a/kimai-plugin/ApiTokenBundle/composer.json b/kimai-plugin/ApiTokenBundle/composer.json new file mode 100644 index 0000000..39f63ab --- /dev/null +++ b/kimai-plugin/ApiTokenBundle/composer.json @@ -0,0 +1,27 @@ +{ + "name": "kimai-mcp/api-token-bundle", + "description": "Adds the missing REST endpoint for creating a personal API token on behalf of a user (admin only).", + "type": "kimai-plugin", + "version": "1.0.0", + "keywords": [ + "kimai", + "kimai-plugin" + ], + "license": "MIT", + "authors": [ + { + "name": "Kimai MCP Contributors" + } + ], + "extra": { + "kimai": { + "require": 26500, + "name": "API Token Provisioning" + } + }, + "autoload": { + "psr-4": { + "KimaiPlugin\\ApiTokenBundle\\": "" + } + } +} From 9fb4803a03db44cabdae8565b280a4314fcf2148 Mon Sep 17 00:00:00 2001 From: Maximilian von Heyden Date: Thu, 13 Aug 2026 09:21:02 +0200 Subject: [PATCH 3/6] feat: automatic Kimai onboarding for OIDC logins Until now every user had to be declared in users.json before they could sign in, together with an API token an administrator first created by hand in Kimai's web UI. With --auto-provision the server resolves a verified OIDC identity against Kimai's user list and has Kimai mint that user's own token at first sign-in, so signing in with the IdP is all a user ever does. Off by default: it needs a non-core Kimai plugin and a ROLE_SUPER_ADMIN token resident in server memory. Nobody should acquire either by upgrading. Every failure mode - no match, ambiguous match, plugin missing, permission missing, Kimai unreachable - answers with the same generic 403 the callback already returned, so enabling the feature cannot regress a deployment. Matching is deliberately strict. Rules run strongest first, and a rule matching more than one user aborts with "ambiguous" instead of falling through to a weaker one: a wrong match hands one employee another employee's token. The token verification is not a substitute for that - it only proves the token belongs to the user we already picked - which is why the two name-based heuristics need --provision-match fuzzy. They were built against a directory whose shape was known, which no upstream deployment is. Persistence follows the OAuth client store: in-memory by default (access and refresh tokens are too, so a restart already means a silent SSO redirect), opt-in --provision-store for deployments that would rather not churn Kimai tokens on every deploy. That file holds tokens in plaintext, so it is written 0600. Also lifts two assumptions that made "SSO and nothing else" unbootable: UsersConfig.load(allow_empty=) and initialize_users() no longer insist on a user existing before the first login. Co-Authored-By: Claude Opus 5 (1M context) --- src/kimai_mcp/oauth.py | 15 + src/kimai_mcp/provisioning.py | 579 ++++++++++++++++++++++++ src/kimai_mcp/streamable_http_server.py | 171 ++++++- src/kimai_mcp/user_config.py | 53 ++- tests/test_oauth.py | 233 ++++++++++ tests/test_provisioning.py | 539 ++++++++++++++++++++++ 6 files changed, 1578 insertions(+), 12 deletions(-) create mode 100644 src/kimai_mcp/provisioning.py create mode 100644 tests/test_provisioning.py diff --git a/src/kimai_mcp/oauth.py b/src/kimai_mcp/oauth.py index 0a81cb0..75881c2 100644 --- a/src/kimai_mcp/oauth.py +++ b/src/kimai_mcp/oauth.py @@ -35,6 +35,7 @@ from starlette.routing import Route from .oidc import OIDCClient, OIDCConfig, OIDCError, OIDCLoginState +from .provisioning import KimaiProvisioner from .user_config import UsersConfig, _env_key_for_slug logger = logging.getLogger(__name__) @@ -121,6 +122,7 @@ def __init__( public_url: str, state_file: str | Path | None = None, oidc_config: OIDCConfig | None = None, + provisioner: KimaiProvisioner | None = None, ): """Initialize the provider. @@ -132,12 +134,16 @@ def __init__( oidc_config: Optional OIDC relying-party config. When set, users authenticate against an external OIDC provider instead of the built-in slug + auth_secret login form. + provisioner: Optional automatic onboarding. When set, an OIDC + identity that matches no configured user is resolved against + Kimai and given its own API token instead of being rejected. """ self.users_config = users_config self.public_url = public_url.rstrip("/") self.state_file = Path(state_file) if state_file else None # Federated OIDC login backend (None -> built-in local login form). self.oidc: OIDCClient | None = OIDCClient(oidc_config) if oidc_config else None + self.provisioner = provisioner # Pending federated logins keyed by the OIDC `state` we sent to the IdP. self._oidc_logins: dict[str, OIDCLoginState] = {} @@ -411,6 +417,15 @@ async def handle_oidc_callback(self, request: Request) -> Response: return self._oidc_error("The identity provider did not return a usable identity.", 401) match = self.users_config.get_user_by_oidc_identity(identity) + if match is None and self.provisioner is not None: + # First sign-in: try to link a Kimai account and mint its token, so + # signing in with the IdP is all the user ever has to do. Every + # failure mode falls through to the same 403 below. + try: + match = await self.provisioner.provision(identity, claims, self.users_config) + # An onboarding problem must never break the login path itself. + except Exception: + logger.exception(f"Automatic provisioning failed for identity '{identity}'") if match is None: # Detailed reason is logged server-side only; the response stays generic # (consistent with the local login form's deliberate non-disclosure). diff --git a/src/kimai_mcp/provisioning.py b/src/kimai_mcp/provisioning.py new file mode 100644 index 0000000..651e066 --- /dev/null +++ b/src/kimai_mcp/provisioning.py @@ -0,0 +1,579 @@ +"""Automatic Kimai onboarding for federated (OIDC) logins. + +Without this, every user has to be declared in ``users.json`` before they can +sign in, together with a Kimai API token that an administrator first had to +create by hand in Kimai's web UI. With ``--auto-provision`` the server instead + +1. resolves the verified OIDC identity to an existing Kimai user + (:func:`resolve_kimai_user`), and +2. has Kimai mint that user's personal API token (:func:`provision_token`), + +both using the configured provisioning admin token. Step 2 needs the +``ApiTokenBundle`` plugin (see ``kimai-plugin/``), because core Kimai can only +create access tokens through its web UI. + +Matching is deliberately strict: a wrong match would hand one employee another +employee's token. Every rule must produce **exactly one** candidate; as soon as +a rule matches more than one user, resolution stops and reports ambiguity +instead of falling through to an even weaker rule. Note that the token +verification in :func:`provision_token` does *not* protect against a wrong +match - it only proves the minted token belongs to the user we already decided +on. That is why the two weakest, name-based rules are opt-in +(``match_mode="fuzzy"``): they were designed against a directory whose shape +was known, which is not something this server can assume about yours. + +If any step fails the caller keeps its existing behaviour - the sign-in is +answered with the same generic "not authorized" page as before - so enabling +the feature cannot regress a working deployment. +""" + +import asyncio +import json +import logging +import re +import secrets +import time +import unicodedata +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, Field, field_validator + +from .client import KimaiAPIError, KimaiClient +from .models import User +from .user_config import SLUG_PATTERN, UserConfig, UsersConfig + +logger = logging.getLogger(__name__) + +DEFAULT_TOKEN_NAME = "Kimai MCP (auto)" + +# Below this length a name part stops being evidence ("jo" would match "johanna", +# "jonas" and "joachim" alike), so the name-part rule ignores shorter fragments. +# A heuristic, not a law - it is tuned for Latin-script given/family names. +MIN_NAME_PART_LENGTH = 4 + +# Separators between the parts of a login or display name. +_NAME_PART_SEPARATORS = re.compile(r"[.\-_+\s]+") + +# Folding for scripts where a diacritic is conventionally *expanded* rather than +# dropped; German and Nordic spellings are the cases this was built for. Every +# other diacritic is handled by the NFKD pass in normalize(). +_TRANSLITERATIONS = { + "ä": "ae", + "ö": "oe", + "ü": "ue", + "ß": "ss", + "å": "a", + "ø": "o", + "æ": "ae", +} + +# Rule names in descending order of strength; see resolve_kimai_user(). +RULES_EXACT = ("email", "username==identity") +RULES_NORMALIZED = RULES_EXACT + ("username==local-part", "normalized") +RULES_FUZZY = RULES_NORMALIZED + ("display-name", "name-part") + +_MATCH_MODES: dict[str, tuple[str, ...]] = { + "exact": RULES_EXACT, + "normalized": RULES_NORMALIZED, + "fuzzy": RULES_FUZZY, +} + + +class ProvisioningConfig(BaseModel): + """Configuration for automatic Kimai onboarding.""" + + kimai_url: str = Field(..., description="Kimai server URL used for provisioned users") + admin_token: str = Field( + ..., + description=( + "Kimai API token of an account holding 'api-token_other_profile' " + "(ROLE_SUPER_ADMIN by default)." + ), + ) + token_name: str = Field( + DEFAULT_TOKEN_NAME, + description=( + "Name given to provisioned tokens. Tokens are replaced by name, so " + "keeping this stable means re-provisioning does not pile up dead " + "tokens in a user's profile." + ), + ) + ssl_verify: bool | str = Field(True, description="SSL verification setting") + match_mode: Literal["exact", "normalized", "fuzzy"] = Field( + "normalized", description="How far to go when matching an identity to a Kimai user" + ) + store_path: str | None = Field( + None, + description=( + "Optional JSON file persisting provisioned users across restarts. " + "Unset means in-memory only; users are re-provisioned on their next " + "sign-in, which is idempotent." + ), + ) + + @field_validator("kimai_url") + @classmethod + def validate_url(cls, v: str) -> str: + v = v.strip().rstrip("/") + if not v.startswith(("http://", "https://")): + raise ValueError("Kimai URL must start with http:// or https://") + return v + + @field_validator("token_name") + @classmethod + def validate_token_name(cls, v: str) -> str: + # Kimai's AccessToken form requires a name; the plugin enforces 2-50. + v = v.strip() + if not 2 <= len(v) <= 50: + raise ValueError("token_name must be between 2 and 50 characters") + return v + + +def normalize(value: str | None) -> str: + """Fold a name/login to a comparable form. + + Lowercases, expands the transliterated characters above, strips remaining + diacritics and drops everything that is not a letter or digit. That makes + ``anna.vondorf``, ``Anna von Dorf`` and ``A.von-Dorf`` comparable, which is + exactly where Kimai logins and IdP addresses tend to differ. + """ + if not value: + return "" + text = value.strip().lower() + for src, dst in _TRANSLITERATIONS.items(): + text = text.replace(src, dst) + decomposed = unicodedata.normalize("NFKD", text) + stripped = "".join(c for c in decomposed if not unicodedata.combining(c)) + return "".join(c for c in stripped if c.isalnum()) + + +def local_part(email: str) -> str: + """The part before the ``@`` (the whole string if there is none).""" + return email.split("@", 1)[0] + + +def name_parts(value: str | None) -> set[str]: + """Normalized name parts of a login or display name. + + ``anna.von-dorf`` -> ``{anna, dorf}`` (``von`` is below the length floor). + """ + if not value: + return set() + parts = (normalize(part) for part in _NAME_PART_SEPARATORS.split(value)) + return {part for part in parts if len(part) >= MIN_NAME_PART_LENGTH} + + +@dataclass +class ResolveResult: + """Outcome of matching an OIDC identity against Kimai's user list.""" + + user: User | None = None + # "matched" | "ambiguous" | "not_found" | "unsupported_identity" + reason: str = "not_found" + # Name of the rule that matched, for logs and support questions. + rule: str | None = None + # Usernames of the competing candidates when reason == "ambiguous". + candidates: list[str] = field(default_factory=list) + + @property + def matched(self) -> bool: + return self.user is not None + + +def resolve_kimai_user( + users: list[User], + identity: str, + *, + display_name: str | None = None, + given_name: str | None = None, + family_name: str | None = None, + match_mode: str = "normalized", +) -> ResolveResult: + """Find the one Kimai user belonging to an OIDC identity. + + Args: + users: All users visible to the provisioning admin token. + identity: Verified identity from the id_token, an email address. + display_name: ``name`` claim, e.g. "Anna von Dorf". + given_name / family_name: ``given_name`` / ``family_name`` claims. + match_mode: ``exact``, ``normalized`` (default) or ``fuzzy`` - see the + module docstring for why the last two rules are not on by default. + + The rules run from strongest to weakest and stop at the first one that + matches anything. Rules that hit several users abort with + ``reason="ambiguous"`` rather than guessing. + """ + # An identity that is not an address cannot be reasoned about here: with + # --oidc-identity-claim sub it is an opaque provider GUID, and running that + # through the name rules would compare a random string against usernames. + # extract_identity() applies the same test to username-shaped claims. + if "@" not in identity: + logger.warning( + "Automatic provisioning needs an email-shaped identity; " + f"'{identity}' is not one. Configure --oidc-identity-claim accordingly." + ) + return ResolveResult(reason="unsupported_identity") + + candidates = [u for u in users if u.enabled] + if not candidates: + return ResolveResult(reason="not_found") + + idp_email = identity.strip().lower() + idp_local = local_part(idp_email) + norm_local = normalize(idp_local) + norm_display = normalize(display_name) + norm_full_name = normalize(f"{given_name or ''}{family_name or ''}") + + def user_keys(user: User) -> set[str]: + """Normalized identifiers a Kimai user can be recognized by.""" + keys = {normalize(user.username), normalize(user.alias)} + if user.email: + keys.add(normalize(local_part(user.email))) + return {k for k in keys if k} + + all_rules: dict[str, list[User]] = { + "email": [u for u in candidates if u.email and u.email.strip().lower() == idp_email], + "username==identity": [ + u for u in candidates if u.username.strip().lower() == idp_email + ], + "username==local-part": [ + u for u in candidates if u.username.strip().lower() == idp_local + ], + "normalized": [u for u in candidates if norm_local and norm_local in user_keys(u)], + "display-name": [ + u + for u in candidates + if normalize(u.alias) + and normalize(u.alias) in {k for k in (norm_display, norm_full_name) if k} + ], + "name-part": [u for u in candidates if _name_part_match(idp_local, u)], + } + + enabled_rules = _MATCH_MODES.get(match_mode, RULES_NORMALIZED) + + for rule in enabled_rules: + matches = all_rules[rule] + if not matches: + continue + if len(matches) > 1: + names = sorted(u.username for u in matches) + logger.warning( + f"Kimai user resolution for '{identity}' is ambiguous via rule '{rule}': {names}" + ) + return ResolveResult(reason="ambiguous", rule=rule, candidates=names) + logger.info( + f"Resolved '{identity}' to Kimai user '{matches[0].username}' " + f"(ID {matches[0].id}) via rule '{rule}'" + ) + return ResolveResult(user=matches[0], reason="matched", rule=rule) + + logger.warning( + f"No Kimai user found for '{identity}' among {len(candidates)} enabled users " + f"(match mode '{match_mode}')" + ) + return ResolveResult(reason="not_found") + + +def _name_part_match(idp_local: str, user: User) -> bool: + """Whether a short address alias refers to the same person as a full login. + + Covers ``anna@`` vs. ``anna.vondorf@``: the address is exactly one *name + part* of the Kimai login (or the other way round for a Kimai login that is + only a first name). + + Deliberately not a character prefix: ``maria`` is a prefix of ``mariana`` + but a different person, and this rule runs last, i.e. for exactly the users + who have no account of their own yet - the ones who would silently be handed + a colleague's token. Only whole parts count, and only when the rule leaves a + single candidate. + """ + idp_parts = name_parts(idp_local) + if not idp_parts: + return False + + user_values = [user.username, user.alias] + if user.email: + user_values.append(local_part(user.email)) + + for value in user_values: + parts = name_parts(value) + if not parts: + continue + # The address is one part of the login, or the login is one part of the + # address ("vondorf@" vs. login "dorf" never matches - parts must be + # equal, not contained). + if idp_parts & parts: + whole = normalize(value) + if normalize(idp_local) in parts or (whole and whole in idp_parts): + return True + return False + + +async def provision_token( + admin_client: KimaiClient, + kimai_url: str, + user: User, + token_name: str = DEFAULT_TOKEN_NAME, + ssl_verify: bool | str = True, +) -> str | None: + """Create a personal API token for ``user`` and verify it before returning it. + + Returns the token, or ``None`` if provisioning is unavailable (plugin + missing, admin token lacks ``api-token_other_profile``, Kimai unreachable). + + The freshly minted token is checked against ``/api/users/me`` before it is + handed out: a token that resolves to a different user than the one we + matched would give somebody else's data to this session, so it is discarded + instead. + """ + try: + created = await admin_client.create_api_token( + user_id=user.id, name=token_name, replace_existing=True + ) + except KimaiAPIError as e: + if e.status_code == 404: + logger.warning( + "Automatic token provisioning unavailable: Kimai has no " + "POST /api/users/{id}/api-token endpoint. Install the ApiTokenBundle " + "plugin (kimai-plugin/ApiTokenBundle) to enable it." + ) + elif e.status_code == 403: + logger.warning( + "Automatic token provisioning refused: the configured provisioning " + "admin token lacks the 'api-token_other_profile' permission " + "(ROLE_SUPER_ADMIN by default)." + ) + else: + logger.error( + f"Token provisioning failed for Kimai user {user.id}: " + f"{e.message} (status {e.status_code})" + ) + return None + + # Verify the token really belongs to the user we resolved. + probe = KimaiClient(kimai_url, created.token, ssl_verify=ssl_verify) + try: + actual = await probe.get_current_user() + except KimaiAPIError as e: + logger.error(f"Provisioned token for user {user.id} could not be verified: {e.message}") + return None + finally: + await probe.close() + + if actual.id != user.id: + logger.error( + f"Provisioned token belongs to Kimai user {actual.id} " + f"('{actual.username}'), expected {user.id} ('{user.username}') - discarding it" + ) + return None + + logger.info( + f"Provisioned Kimai API token '{token_name}' for user '{user.username}' (ID {user.id})" + ) + return created.token + + +class ProvisionedUserStore: + """Optional JSON file remembering which Kimai account belongs to an identity. + + Holds plaintext Kimai API tokens, so the file is written with mode 0600. + Persistence is a convenience: without it a restart simply re-provisions + every user on their next sign-in. + """ + + def __init__(self, path: str | Path): + self.path = Path(path) + + def load_into(self, users: UsersConfig) -> int: + """Insert persisted users into ``users``, skipping anything already there. + + A hand-written configuration always wins: neither a slug nor an identity + that is already declared is overwritten. + """ + if not self.path.exists(): + return 0 + try: + with self.path.open(encoding="utf-8") as f: + data = json.load(f) + # A corrupt store must not stop the server from booting; the affected + # users are simply provisioned again on their next sign-in. + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to load provisioned users from {self.path}: {e}") + return 0 + + loaded = 0 + for identity, entry in data.items(): + if users.get_user_by_oidc_identity(identity) is not None: + continue + slug = entry.get("slug") + if not slug or slug in users.users: + continue + try: + users.add_user( + slug, + UserConfig( + kimai_url=entry["kimai_url"], + kimai_token=entry["kimai_token"], + ssl_verify=entry.get("ssl_verify", True), + auth_secret=None, + oidc_identity=identity, + ), + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Skipping provisioned user '{identity}' from {self.path}: {e}") + continue + loaded += 1 + + logger.info(f"Loaded {loaded} provisioned user(s) from {self.path}") + return loaded + + def add(self, identity: str, slug: str, config: UserConfig) -> None: + """Append one provisioned user and rewrite the file atomically.""" + try: + data: dict[str, Any] = {} + if self.path.exists(): + with self.path.open(encoding="utf-8") as f: + data = json.load(f) + data[identity.strip().lower()] = { + "slug": slug, + "kimai_url": config.kimai_url, + "kimai_token": config.kimai_token, + "ssl_verify": config.ssl_verify, + "created_at": int(time.time()), + } + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_suffix(self.path.suffix + ".tmp") + with tmp_path.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + # Set the mode before the rename so the file is never briefly + # world-readable while it already holds tokens. + tmp_path.chmod(0o600) + tmp_path.replace(self.path) + # Persistence is a convenience; a failing write must not invalidate the + # in-memory provisioning that just succeeded. + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to persist provisioned user to {self.path}: {e}") + + +class KimaiProvisioner: + """Turns a verified OIDC identity into a usable Kimai user configuration.""" + + def __init__(self, config: ProvisioningConfig, store: ProvisionedUserStore | None = None): + self.config = config + self.store = store + # Two parallel sign-ins of the same identity would otherwise mint two + # tokens, the second silently invalidating the first (tokens are + # replaced by name). + self._lock = asyncio.Lock() + + def _client(self) -> KimaiClient: + return KimaiClient( + self.config.kimai_url, self.config.admin_token, ssl_verify=self.config.ssl_verify + ) + + @staticmethod + def generate_slug(taken: Mapping[str, Any] | None = None) -> str: + """A slug with the entropy of the ones users.example.json tells users to generate.""" + taken = taken or {} + for _ in range(10): + slug = secrets.token_urlsafe(12) + if SLUG_PATTERN.match(slug) and slug not in taken: + return slug + raise RuntimeError("Could not generate a free user slug") + + async def check_prerequisites(self) -> None: + """Log at startup whether provisioning can actually work. + + Never raises: a Kimai that is briefly unreachable at boot must not stop + the server, and provisioning failures are already handled per request. + """ + client = self._client() + try: + version = await client.get_version() + me = await client.get_current_user() + logger.info( + f"Provisioning admin token belongs to '{me.username}' on Kimai " + f"{version.version} at {self.config.kimai_url}" + ) + plugins = await client.get_plugins() + if not any(p.name == "ApiTokenBundle" for p in plugins): + logger.error( + "Automatic provisioning is enabled but the ApiTokenBundle plugin is not " + "installed on this Kimai instance. POST /api/users/{id}/api-token will " + "return 404 and every first-time sign-in will be rejected. " + "See kimai-plugin/ApiTokenBundle/README.md." + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Could not verify the provisioning prerequisites: {e}") + finally: + await client.close() + + async def provision( + self, identity: str, claims: Mapping[str, Any], users: UsersConfig + ) -> tuple[str, UserConfig] | None: + """Resolve ``identity`` to a Kimai user and give it a token and a slug. + + Returns ``(slug, UserConfig)`` - the same shape as + :meth:`UsersConfig.get_user_by_oidc_identity`, so a caller can treat a + provisioned and a configured user identically - or ``None`` when the + identity cannot be onboarded. The reason is logged, never returned: the + sign-in response must not reveal whether an identity matched a Kimai + account. + """ + async with self._lock: + # Another request may have provisioned this identity while we waited. + existing = users.get_user_by_oidc_identity(identity) + if existing is not None: + return existing + + client = self._client() + try: + kimai_users = await client.get_users() + result = resolve_kimai_user( + kimai_users, + identity, + display_name=claims.get("name"), + given_name=claims.get("given_name"), + family_name=claims.get("family_name"), + match_mode=self.config.match_mode, + ) + if result.user is None: + return None + + token = await provision_token( + client, + self.config.kimai_url, + result.user, + token_name=self.config.token_name, + ssl_verify=self.config.ssl_verify, + ) + finally: + # A super-admin token has no business sitting in a long-lived + # idle connection, so the client lives for one callback only. + await client.close() + + if token is None: + return None + + config = UserConfig( + kimai_url=self.config.kimai_url, + kimai_token=token, + ssl_verify=self.config.ssl_verify, + # Provisioned users authenticate through the IdP only; without a + # secret the built-in login form cannot be used for them. + auth_secret=None, + oidc_identity=identity, + ) + slug = self.generate_slug(users.users) + users.add_user(slug, config) + + if self.store is not None: + await asyncio.to_thread(self.store.add, identity, slug, config) + + logger.info( + f"Provisioned '{identity}' -> Kimai user '{result.user.username}' (slug '{slug}')" + ) + return slug, config diff --git a/src/kimai_mcp/streamable_http_server.py b/src/kimai_mcp/streamable_http_server.py index 0159fce..71b722f 100644 --- a/src/kimai_mcp/streamable_http_server.py +++ b/src/kimai_mcp/streamable_http_server.py @@ -62,6 +62,7 @@ from .client import KimaiAPIError, KimaiClient from .oauth import KimaiOAuthProvider from .oidc import OIDCConfig +from .provisioning import KimaiProvisioner, ProvisionedUserStore, ProvisioningConfig from .security import ( EnumerationProtection, RateLimitConfig, @@ -337,6 +338,7 @@ def __init__( disable_legacy_slugs: bool = False, oauth_state_file: str | None = None, oidc_config: OIDCConfig | None = None, + provisioning_config: ProvisioningConfig | None = None, ): """Initialize the server. @@ -355,6 +357,9 @@ def __init__( oidc_config: Optional OIDC relying-party config. When set, the OAuth login step federates to an external OIDC provider instead of the built-in slug + auth_secret form. + provisioning_config: Optional automatic onboarding config. When set, + an OIDC identity without a configured user is resolved against + Kimai and given its own API token at first sign-in. """ self.users_config = users_config self.host = host @@ -374,6 +379,27 @@ def __init__( self.public_url = (public_url or f"http://localhost:{port}").rstrip("/") + # Automatic onboarding (optional). The store is loaded here rather than + # in main() so that a server constructed directly (tests, embedding) + # behaves the same as one started from the CLI. + self.provisioner: KimaiProvisioner | None = None + if provisioning_config is not None: + store = ( + ProvisionedUserStore(provisioning_config.store_path) + if provisioning_config.store_path + else None + ) + if store is not None: + store.load_into(users_config) + self.provisioner = KimaiProvisioner(provisioning_config, store) + if self.legacy_slugs_enabled: + logger.warning( + "Automatic provisioning is enabled while the deprecated /mcp/{slug} " + "routes are still served. Provisioned slugs are as strong as generated " + "ones, but the slug alone is a credential on that route - consider " + "--disable-legacy-slugs." + ) + # OAuth 2.1 authorization server settings (SDK scaffolding) self.auth_settings = AuthSettings( issuer_url=AnyHttpUrl(self.public_url), @@ -387,6 +413,7 @@ def __init__( public_url=self.public_url, state_file=oauth_state_file, oidc_config=oidc_config, + provisioner=self.provisioner, ) # Rate limiting configuration @@ -417,7 +444,10 @@ async def initialize_users(self) -> None: self.user_sessions[slug] = session logger.info(f"Initialized session for user '{slug}'") - if not self.user_sessions: + # With automatic provisioning the user set is discovered at login time, + # so "no sessions yet" is the normal state of a fresh deployment rather + # than a misconfiguration. + if not self.user_sessions and self.provisioner is None: raise RuntimeError("No user sessions could be initialized") async def cleanup_users(self) -> None: @@ -509,6 +539,8 @@ async def lifespan(self, app: Starlette) -> AsyncIterator[None]: # Initialize users await self.initialize_users() self._warn_low_entropy_slugs() + if self.provisioner is not None: + await self.provisioner.check_prerequisites() # Periodic security cleanup (rate limiter / enumeration protection / OAuth) cleanup_task = asyncio.create_task(self._security_cleanup_loop()) @@ -823,6 +855,74 @@ def create_parser() -> argparse.ArgumentParser: ), ) + # Automatic onboarding (optional; requires --auth-backend oidc and the + # ApiTokenBundle plugin on the Kimai server) + parser.add_argument( + "--auto-provision", + action="store_true", + help=( + "Give an OIDC identity without a configured user its own Kimai API token " + "at first sign-in instead of rejecting it. Requires --auth-backend oidc and " + "the ApiTokenBundle plugin on the Kimai server " + "(or set KIMAI_MCP_AUTO_PROVISION=true)" + ), + ) + parser.add_argument( + "--provision-kimai-url", + metavar="URL", + help=( + "Kimai URL written into provisioned user configs " + "(required for --auto-provision; or KIMAI_MCP_PROVISION_KIMAI_URL)" + ), + ) + parser.add_argument( + "--provision-admin-token", + metavar="TOKEN", + help=( + "Kimai API token used to mint per-user tokens. Needs the " + "'api-token_other_profile' permission (ROLE_SUPER_ADMIN by default). " + "Prefer the KIMAI_MCP_PROVISION_ADMIN_TOKEN env var over the CLI flag." + ), + ) + parser.add_argument( + "--provision-token-name", + metavar="NAME", + help=( + "Name of the tokens this server creates, as shown in the user's Kimai " + "profile (default: 'Kimai MCP (auto)'; or KIMAI_MCP_PROVISION_TOKEN_NAME)" + ), + ) + parser.add_argument( + "--provision-match", + choices=["exact", "normalized", "fuzzy"], + default=None, + help=( + "How hard to try when matching an identity to a Kimai user: 'exact' " + "(email or username equals the identity), 'normalized' (default, also " + "compares the address local part and folds dots/umlauts) or 'fuzzy' " + "(also compares display names and single name parts). " + "Or set KIMAI_MCP_PROVISION_MATCH." + ), + ) + parser.add_argument( + "--provision-store", + metavar="FILE", + help=( + "JSON file persisting provisioned users across restarts. Holds Kimai API " + "tokens in plaintext and is written with mode 0600. Omit to keep them " + "in memory only, in which case users are re-provisioned on their next " + "sign-in. Or set KIMAI_MCP_PROVISION_STORE." + ), + ) + parser.add_argument( + "--provision-ssl-verify", + metavar="VALUE", + help=( + "SSL verification for provisioning calls: true, false or a CA path " + "(default: true; or KIMAI_MCP_PROVISION_SSL_VERIFY)" + ), + ) + # Security settings parser.add_argument( "--rate-limit-rpm", @@ -904,6 +1004,66 @@ def _build_oidc_config(args: argparse.Namespace) -> OIDCConfig | None: return OIDCConfig(**kwargs) +def _build_provisioning_config( + args: argparse.Namespace, oidc_config: OIDCConfig | None +) -> ProvisioningConfig | None: + """Build the automatic-onboarding config from CLI flags / env vars. + + Returns None when the feature is off. Raises ValueError when it is on but + half-configured, rather than starting a server that rejects every + first-time sign-in for a reason only visible in the log. + """ + enabled = args.auto_provision or ( + os.getenv("KIMAI_MCP_AUTO_PROVISION", "").lower() in ("1", "true", "yes") + ) + if not enabled: + return None + + if oidc_config is None: + raise ValueError( + "--auto-provision requires --auth-backend oidc: there is no verified " + "identity to resolve against Kimai without a federated login." + ) + + kimai_url = args.provision_kimai_url or os.getenv("KIMAI_MCP_PROVISION_KIMAI_URL") + admin_token = args.provision_admin_token or os.getenv("KIMAI_MCP_PROVISION_ADMIN_TOKEN") + missing = [ + name + for name, val in ( + ("--provision-kimai-url", kimai_url), + ("--provision-admin-token", admin_token), + ) + if not val + ] + if missing: + raise ValueError( + f"--auto-provision requires {', '.join(missing)} " + f"(or the matching KIMAI_MCP_PROVISION_* environment variables)" + ) + + kwargs: dict[str, object] = {"kimai_url": kimai_url, "admin_token": admin_token} + token_name = args.provision_token_name or os.getenv("KIMAI_MCP_PROVISION_TOKEN_NAME") + if token_name: + kwargs["token_name"] = token_name + match_mode = args.provision_match or os.getenv("KIMAI_MCP_PROVISION_MATCH") + if match_mode: + kwargs["match_mode"] = match_mode + store_path = args.provision_store or os.getenv("KIMAI_MCP_PROVISION_STORE") + if store_path: + kwargs["store_path"] = store_path + ssl_verify = args.provision_ssl_verify or os.getenv("KIMAI_MCP_PROVISION_SSL_VERIFY") + if ssl_verify: + kwargs["ssl_verify"] = ssl_verify + + config = ProvisioningConfig(**kwargs) + logger.info( + f"Automatic provisioning enabled (Kimai: {config.kimai_url}, " + f"match mode: {config.match_mode}, " + f"store: {config.store_path or 'in-memory'})" + ) + return config + + def main() -> int: """Main entry point.""" # Load environment variables @@ -935,9 +1095,13 @@ def main() -> int: try: oidc_config = _build_oidc_config(args) + provisioning_config = _build_provisioning_config(args, oidc_config) - # Load users config - users_config = UsersConfig.load(args.users_config) + # Load users config. With provisioning on, a configuration that declares + # no users is legitimate - they arrive at their first sign-in. + users_config = UsersConfig.load( + args.users_config, allow_empty=provisioning_config is not None + ) logger.info(f"Loaded configuration for {len(users_config.users)} user(s)") # Create and run server @@ -951,6 +1115,7 @@ def main() -> int: disable_legacy_slugs=disable_legacy_slugs, oauth_state_file=oauth_state_file, oidc_config=oidc_config, + provisioning_config=provisioning_config, ) server.run() return 0 diff --git a/src/kimai_mcp/user_config.py b/src/kimai_mcp/user_config.py index 45ac958..542516e 100644 --- a/src/kimai_mcp/user_config.py +++ b/src/kimai_mcp/user_config.py @@ -108,7 +108,7 @@ def _apply_env_overrides(users: dict[str, UserConfig]) -> None: logger.info(f"Loaded oidc_identity for user '{slug}' from environment") @classmethod - def from_file(cls, path: str | Path) -> "UsersConfig": + def from_file(cls, path: str | Path, *, allow_empty: bool = False) -> "UsersConfig": """Load users configuration from a JSON file. Expected format: @@ -123,6 +123,12 @@ def from_file(cls, path: str | Path) -> "UsersConfig": "kimai_token": "api_token_for_anna" } } + + Args: + path: Path to the JSON file. + allow_empty: Accept a file that declares no users. Only meaningful + with automatic provisioning enabled, where the user set is + filled in at login time rather than declared up front. """ path = Path(path) if not path.exists(): @@ -153,14 +159,14 @@ def from_file(cls, path: str | Path) -> "UsersConfig": logger.error(f"Error parsing config for user '{slug}': {e}") raise ValueError(f"Invalid config for user '{slug}': {e}") from e - if not users: + if not users and not allow_empty: raise ValueError("No users configured in config file") cls._apply_env_overrides(users) return cls(users=users) @classmethod - def from_env(cls) -> "UsersConfig": + def from_env(cls, *, allow_empty: bool = False) -> "UsersConfig": """Load users configuration from environment variables. Supports two formats: @@ -173,6 +179,10 @@ def from_env(cls) -> "UsersConfig": KIMAI_USER_MAX_TOKEN=xxx KIMAI_USER_MAX_SSL_VERIFY=true (optional) KIMAI_USER_MAX_AUTH_SECRET=oauth-login-secret (optional) + + Args: + allow_empty: Accept an environment that declares no users. Only + meaningful with automatic provisioning enabled. """ users = {} @@ -191,7 +201,7 @@ def from_env(cls) -> "UsersConfig": continue users[slug] = UserConfig(**user_data) logger.info(f"Loaded config for user '{slug}' from USERS_CONFIG") - if not users: + if not users and not allow_empty: raise ValueError("No valid users configured in USERS_CONFIG") cls._apply_env_overrides(users) return cls(users=users) @@ -228,7 +238,7 @@ def from_env(cls) -> "UsersConfig": ) logger.info(f"Loaded config for user '{slug}' from env vars") - if not users: + if not users and not allow_empty: raise ValueError( "No users configured. Set USERS_CONFIG or KIMAI_USER_*_URL/TOKEN env vars, " "or use --users-config to specify a config file." @@ -237,7 +247,9 @@ def from_env(cls) -> "UsersConfig": return cls(users=users) @classmethod - def load(cls, config_path: str | Path | None = None) -> "UsersConfig": + def load( + cls, config_path: str | Path | None = None, *, allow_empty: bool = False + ) -> "UsersConfig": """Load users configuration from file or environment. Priority: @@ -245,21 +257,28 @@ def load(cls, config_path: str | Path | None = None) -> "UsersConfig": 2. USERS_CONFIG_FILE env var 3. USERS_CONFIG env var (JSON) 4. Individual KIMAI_USER_* env vars + + Args: + config_path: Explicit path to a users config file. + allow_empty: Tolerate a configuration that declares no users at all. + Set when automatic provisioning is on: there the user set is + discovered at login time, so demanding one up front would make + the "sign in and nothing else" deployment impossible to boot. """ # Check for explicit path if config_path: logger.info(f"Loading users config from: {config_path}") - return cls.from_file(config_path) + return cls.from_file(config_path, allow_empty=allow_empty) # Check for config file env var config_file_env = os.getenv("USERS_CONFIG_FILE") if config_file_env: logger.info(f"Loading users config from USERS_CONFIG_FILE: {config_file_env}") - return cls.from_file(config_file_env) + return cls.from_file(config_file_env, allow_empty=allow_empty) # Fall back to environment variables logger.info("Loading users config from environment variables") - return cls.from_env() + return cls.from_env(allow_empty=allow_empty) def get_user(self, slug: str) -> UserConfig | None: """Get configuration for a specific user.""" @@ -275,6 +294,22 @@ def get_user_by_oidc_identity(self, value: str) -> tuple[str, UserConfig] | None return slug, config return None + def add_user(self, slug: str, config: UserConfig) -> None: + """Register a user at runtime (used by automatic provisioning). + + Raises: + ValueError: if the slug is unusable in a URL, or already taken. + Overwriting is refused rather than silently replacing a + hand-written configuration. + """ + if not SLUG_PATTERN.match(slug): + raise ValueError( + f"Invalid user slug '{slug}': only letters, digits, '-' and '_' are allowed" + ) + if slug in self.users: + raise ValueError(f"User slug '{slug}' is already configured") + self.users[slug] = config + def list_users(self) -> list[str]: """List all configured user slugs.""" return list(self.users.keys()) diff --git a/tests/test_oauth.py b/tests/test_oauth.py index a5d7ae0..5c7d9cd 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -826,3 +826,236 @@ def test_oidc_callback_provider_error_rejected(make_client, oidc_users_config, m follow_redirects=False, ) assert resp.status_code == 400, resp.text + + +# --------------------------------------------------------------------------- +# Automatic provisioning (OIDC backend + --auto-provision) +# --------------------------------------------------------------------------- + +import json + +from kimai_mcp.client import KimaiAPIError +from kimai_mcp.models import AccessTokenCreated, Plugin, User +from kimai_mcp.provisioning import ProvisioningConfig + +NEWCOMER_EMAIL = "bob.brown@example.com" + + +def _kimai_user(user_id: int, username: str, email: str | None = None) -> User: + return User(id=user_id, username=username, email=email, enabled=True) + + +def _make_provisioning_kimai(kimai_users, *, mint_error=None, probe_user=None): + """A network-free KimaiClient standing in for both admin client and probe. + + The returned class exposes ``.calls``: every create_api_token() it saw. + """ + calls: list[dict] = [] + + class _FakeKimai: + def __init__(self, base_url, api_token, **kwargs): + self.base_url = base_url + self.api_token = api_token + + async def get_users(self, **kwargs): + return kimai_users + + async def get_version(self): + return FakeVersion() + + async def get_plugins(self): + return [Plugin(name="ApiTokenBundle", version="1.0.0")] + + async def create_api_token(self, user_id: int, name: str, replace_existing: bool = True): + calls.append({"user_id": user_id, "name": name}) + if mint_error is not None: + raise mint_error + return AccessTokenCreated(id=len(calls), name=name, token=f"minted-{user_id}") + + async def get_current_user(self): + # Only the probe client is built from a minted token; the admin + # client asks the same question during the startup check. + if not self.api_token.startswith("minted-"): + return kimai_users[0] + if probe_user is not None: + return probe_user + uid = int(self.api_token.rsplit("-", 1)[-1]) + return next(u for u in kimai_users if u.id == uid) + + async def close(self): + pass + + _FakeKimai.calls = calls + return _FakeKimai + + +def _provisioning_client(make_client, monkeypatch, fake_kimai, *, users_config=None, **kwargs): + monkeypatch.setattr("kimai_mcp.oauth.OIDCClient", _make_fake_oidc(NEWCOMER_EMAIL)) + monkeypatch.setattr("kimai_mcp.provisioning.KimaiClient", fake_kimai) + return make_client( + users_config=users_config if users_config is not None else UsersConfig(), + oidc_config=OIDCConfig(issuer="https://idp.test", client_id="cid"), + provisioning_config=ProvisioningConfig( + kimai_url="https://kimai.example.com", admin_token="admin-token", **kwargs + ), + ) + + +def _oidc_callback(http: TestClient, oidc_state: str): + return http.get( + "/oauth/oidc/callback", + params={"code": "idp-code", "state": oidc_state}, + follow_redirects=False, + ) + + +def _sign_in(http: TestClient, client_id: str): + _, challenge = pkce_pair() + return _oidc_callback(http, _oidc_authorize(http, client_id, challenge)) + + +def test_provisioning_onboards_an_unknown_identity_end_to_end(make_client, monkeypatch): + """The whole point: an identity nobody configured signs in and reaches /mcp.""" + fake = _make_provisioning_kimai([_kimai_user(7, "bob.brown", email=NEWCOMER_EMAIL)]) + http = _provisioning_client(make_client, monkeypatch, fake) + + client_info = register_client(http) + verifier, challenge = pkce_pair() + oidc_state = _oidc_authorize(http, client_info["client_id"], challenge) + + resp = _oidc_callback(http, oidc_state) + assert resp.status_code == 302, resp.text + code = parse_qs(urlparse(resp.headers["location"]).query)["code"][0] + + resp = http.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + "client_id": client_info["client_id"], + "code_verifier": verifier, + }, + ) + assert resp.status_code == 200, resp.text + access_token = resp.json()["access_token"] + + # The runtime slug has to be picked up by the on-demand session path. + resp = http.post( + "/mcp", + json=INIT_PAYLOAD, + headers={**MCP_HEADERS, "Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == 200, resp.text + assert fake.calls == [{"user_id": 7, "name": "Kimai MCP (auto)"}] + + +def test_provisioning_without_a_matching_kimai_user_stays_forbidden(make_client, monkeypatch): + fake = _make_provisioning_kimai([_kimai_user(1, "someone.else", email="else@example.com")]) + users_config = UsersConfig() + http = _provisioning_client(make_client, monkeypatch, fake, users_config=users_config) + + resp = _sign_in(http, register_client(http)["client_id"]) + + assert resp.status_code == 403, resp.text + assert users_config.users == {} + + +def test_provisioning_refuses_an_ambiguous_match(make_client, monkeypatch): + fake = _make_provisioning_kimai( + [ + _kimai_user(1, "bob.a", email=NEWCOMER_EMAIL), + _kimai_user(2, "bob.b", email=NEWCOMER_EMAIL), + ] + ) + users_config = UsersConfig() + http = _provisioning_client(make_client, monkeypatch, fake, users_config=users_config) + + resp = _sign_in(http, register_client(http)["client_id"]) + + assert resp.status_code == 403, resp.text + assert fake.calls == [] + assert users_config.users == {} + + +def test_provisioning_without_the_plugin_stays_forbidden(make_client, monkeypatch): + fake = _make_provisioning_kimai( + [_kimai_user(7, "bob.brown", email=NEWCOMER_EMAIL)], + mint_error=KimaiAPIError("Not Found", 404), + ) + users_config = UsersConfig() + http = _provisioning_client(make_client, monkeypatch, fake, users_config=users_config) + + resp = _sign_in(http, register_client(http)["client_id"]) + + assert resp.status_code == 403, resp.text + assert users_config.users == {} + + +def test_provisioning_discards_a_token_that_belongs_to_someone_else(make_client, monkeypatch): + """The security invariant, asserted end-to-end and not only as a unit test.""" + fake = _make_provisioning_kimai( + [_kimai_user(7, "bob.brown", email=NEWCOMER_EMAIL)], + probe_user=_kimai_user(99, "someone.else"), + ) + users_config = UsersConfig() + http = _provisioning_client(make_client, monkeypatch, fake, users_config=users_config) + + resp = _sign_in(http, register_client(http)["client_id"]) + + assert resp.status_code == 403, resp.text + assert users_config.users == {} + + +def test_a_configured_identity_is_never_provisioned(make_client, monkeypatch, oidc_users_config): + """Hand-written configuration wins; Kimai admin is not contacted at all.""" + oidc_users_config.users[USER_SLUG].oidc_identity = NEWCOMER_EMAIL + fake = _make_provisioning_kimai([_kimai_user(7, "bob.brown", email=NEWCOMER_EMAIL)]) + http = _provisioning_client(make_client, monkeypatch, fake, users_config=oidc_users_config) + + resp = _sign_in(http, register_client(http)["client_id"]) + + assert resp.status_code == 302, resp.text + assert fake.calls == [] + + +def test_second_sign_in_reuses_the_slug_and_mints_once(make_client, monkeypatch): + fake = _make_provisioning_kimai([_kimai_user(7, "bob.brown", email=NEWCOMER_EMAIL)]) + users_config = UsersConfig() + http = _provisioning_client(make_client, monkeypatch, fake, users_config=users_config) + + client_id = register_client(http)["client_id"] + for _ in range(2): + assert _sign_in(http, client_id).status_code == 302 + + assert len(fake.calls) == 1 + assert len(users_config.users) == 1 + + +def test_provisioning_writes_nothing_without_a_store(make_client, monkeypatch, tmp_path): + fake = _make_provisioning_kimai([_kimai_user(7, "bob.brown", email=NEWCOMER_EMAIL)]) + http = _provisioning_client(make_client, monkeypatch, fake) + + assert _sign_in(http, register_client(http)["client_id"]).status_code == 302 + assert list(tmp_path.iterdir()) == [] + + +def test_provisioning_persists_to_the_store_when_configured(make_client, monkeypatch, tmp_path): + store = tmp_path / "provisioned.json" + fake = _make_provisioning_kimai([_kimai_user(7, "bob.brown", email=NEWCOMER_EMAIL)]) + http = _provisioning_client(make_client, monkeypatch, fake, store_path=str(store)) + + assert _sign_in(http, register_client(http)["client_id"]).status_code == 302 + + persisted = json.loads(store.read_text(encoding="utf-8")) + assert persisted[NEWCOMER_EMAIL]["kimai_token"] == "minted-7" + + +def test_server_boots_without_any_configured_user_when_provisioning_is_on( + make_client, monkeypatch +): + """A "sign in and nothing else" deployment has no users until someone does.""" + fake = _make_provisioning_kimai([_kimai_user(7, "bob.brown", email=NEWCOMER_EMAIL)]) + http = _provisioning_client(make_client, monkeypatch, fake) + # Reaching the metadata endpoint at all means the lifespan came up. + assert http.get("/.well-known/oauth-authorization-server").status_code == 200 diff --git a/tests/test_provisioning.py b/tests/test_provisioning.py new file mode 100644 index 0000000..b99d5da --- /dev/null +++ b/tests/test_provisioning.py @@ -0,0 +1,539 @@ +"""Tests for automatic Kimai onboarding of federated (OIDC) logins. + +The matching rules are the security-relevant part here: a wrong match would hand +one employee another employee's API token, so every rule is tested for the +single-match case *and* for the ambiguous case that must refuse to guess. +""" + +import json +import stat +import sys +from typing import ClassVar + +import pytest + +from kimai_mcp import provisioning +from kimai_mcp.client import KimaiAPIError +from kimai_mcp.models import AccessTokenCreated, User +from kimai_mcp.provisioning import ( + ProvisionedUserStore, + normalize, + provision_token, + resolve_kimai_user, +) +from kimai_mcp.user_config import SLUG_PATTERN, UserConfig, UsersConfig + +KIMAI_URL = "https://kimai.example.com" + + +def user( + user_id: int, + username: str, + alias: str | None = None, + email: str | None = None, + enabled: bool = True, +) -> User: + return User(id=user_id, username=username, alias=alias, email=email, enabled=enabled) + + +# --------------------------------------------------------------------------- +# normalize() +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("Anna von Dorf", "annavondorf"), + ("anna.vondorf", "annavondorf"), + ("A.von-Dorf", "avondorf"), + ("Jörg Müller", "joergmueller"), + ("Weiß", "weiss"), + ("José", "jose"), + (None, ""), + ("", ""), + ], +) +def test_normalize(value, expected): + assert normalize(value) == expected + + +# --------------------------------------------------------------------------- +# resolve_kimai_user() - one rule per test, strongest first +# --------------------------------------------------------------------------- + + +def test_matches_on_email(): + users = [user(1, "someone.else"), user(7, "asm", email="Anna.Smith@example.com")] + result = resolve_kimai_user(users, "anna.smith@example.com") + assert result.matched + assert result.user.id == 7 + assert result.rule == "email" + + +def test_matches_on_username_equal_to_identity(): + users = [user(3, "anna.smith@example.com")] + result = resolve_kimai_user(users, "anna.smith@example.com") + assert result.user.id == 3 + assert result.rule == "username==identity" + + +def test_matches_on_username_equal_to_local_part(): + users = [user(4, "anna.smith")] + result = resolve_kimai_user(users, "anna.smith@example.com") + assert result.user.id == 4 + assert result.rule == "username==local-part" + + +def test_matches_normalized_alias(): + """anna.vondorf@ vs. the Kimai alias 'Anna von Dorf'.""" + users = [user(9, "avd", alias="Anna von Dorf")] + result = resolve_kimai_user(users, "anna.vondorf@example.com") + assert result.user.id == 9 + assert result.rule == "normalized" + + +def test_matches_display_name_claim(): + users = [user(11, "as2", alias="Anna Smith")] + result = resolve_kimai_user( + users, "a.smith.extern@example.com", display_name="Anna Smith", match_mode="fuzzy" + ) + assert result.user.id == 11 + assert result.rule == "display-name" + + +def test_matches_given_and_family_name(): + users = [user(12, "jm", alias="Jörg Müller")] + result = resolve_kimai_user( + users, "jmueller99@example.com", given_name="Jörg", family_name="Müller", + match_mode="fuzzy", + ) + assert result.user.id == 12 + assert result.rule == "display-name" + + +def test_matches_short_alias_address_by_name_part(): + """anna@ vs. anna.vondorf@ - the same person with a shorter address.""" + users = [ + user(20, "anna.vondorf", email="anna.vondorf@example.com"), + user(21, "sabine.schmidt", email="sabine.schmidt@example.com"), + ] + result = resolve_kimai_user(users, "anna@example.com", match_mode="fuzzy") + assert result.user.id == 20 + assert result.rule == "name-part" + + +def test_matches_when_the_kimai_login_is_only_a_first_name(): + users = [user(23, "annabel")] + result = resolve_kimai_user(users, "annabel.vondorf@example.com", match_mode="fuzzy") + assert result.user.id == 23 + assert result.rule == "name-part" + + +def test_name_part_rule_ignores_too_short_fragments(): + """'avd' must not silently match 'avdhoffmann'.""" + users = [user(22, "avdhoffmann", email="avdhoffmann@example.com")] + result = resolve_kimai_user(users, "avd@example.com", match_mode="fuzzy") + assert not result.matched + assert result.reason == "not_found" + + +def test_name_part_rule_does_not_match_a_mere_character_prefix(): + """maria@ must NOT be handed Mariana's account. + + This rule only ever runs for people who have no Kimai account of their own, + i.e. exactly those who would silently receive a colleague's token. + """ + users = [user(24, "mariana.schmidt", email="mariana.schmidt@example.com")] + result = resolve_kimai_user(users, "maria@example.com", match_mode="fuzzy") + assert not result.matched + assert result.reason == "not_found" + + +def test_ambiguous_name_part_refuses_to_guess(): + users = [ + user(30, "anna.vondorf", email="anna.vondorf@example.com"), + user(31, "anna.schmidt", email="anna.schmidt@example.com"), + ] + result = resolve_kimai_user(users, "anna@example.com", match_mode="fuzzy") + assert not result.matched + assert result.reason == "ambiguous" + assert result.candidates == ["anna.schmidt", "anna.vondorf"] + + +def test_ambiguity_does_not_fall_through_to_weaker_rules(): + """Two users share the email; a weaker rule must not 'resolve' that for us.""" + users = [ + user(40, "a.smith", email="anna.smith@example.com"), + user(41, "anna.smith", email="anna.smith@example.com"), + ] + result = resolve_kimai_user(users, "anna.smith@example.com") + assert result.reason == "ambiguous" + assert result.rule == "email" + + +def test_disabled_users_are_ignored(): + users = [user(50, "anna.smith", email="anna.smith@example.com", enabled=False)] + result = resolve_kimai_user(users, "anna.smith@example.com") + assert not result.matched + assert result.reason == "not_found" + + +def test_no_users_at_all(): + assert resolve_kimai_user([], "anna.smith@example.com").reason == "not_found" + + +def test_email_match_is_case_insensitive(): + users = [user(60, "as", email="Anna.Smith@Example.COM")] + assert resolve_kimai_user(users, "ANNA.SMITH@example.com").user.id == 60 + + +# --------------------------------------------------------------------------- +# Match modes +# --------------------------------------------------------------------------- + + +def test_normalized_mode_does_not_apply_the_name_rules(): + """The default must not reach the two heuristics.""" + users = [user(20, "anna.vondorf", email="anna.vondorf@example.com")] + assert resolve_kimai_user(users, "anna@example.com").reason == "not_found" + + +def test_exact_mode_does_not_apply_the_local_part_rule(): + users = [user(4, "anna.smith")] + assert resolve_kimai_user(users, "anna.smith@example.com", match_mode="exact").reason == ( + "not_found" + ) + + +def test_an_identity_that_is_not_an_address_is_refused(): + """--oidc-identity-claim sub would otherwise feed a GUID to the name rules.""" + users = [user(1, "9f1c8b2e-0000-4a3d-9f11-abcdef012345")] + result = resolve_kimai_user(users, "9f1c8b2e-0000-4a3d-9f11-abcdef012345", match_mode="fuzzy") + assert not result.matched + assert result.reason == "unsupported_identity" + + +# --------------------------------------------------------------------------- +# provision_token() +# --------------------------------------------------------------------------- + + +class FakeAdminClient: + """Admin-side client: only create_api_token() is exercised.""" + + def __init__(self, result=None, error: KimaiAPIError | None = None): + self._result = result + self._error = error + self.calls: list[dict] = [] + + async def create_api_token(self, user_id: int, name: str, replace_existing: bool = True): + self.calls.append( + {"user_id": user_id, "name": name, "replace_existing": replace_existing} + ) + if self._error is not None: + raise self._error + return self._result + + +class FakeProbeClient: + """Stands in for the KimaiClient built from the freshly minted token.""" + + def __init__(self, me: User | None = None, error: KimaiAPIError | None = None): + self.me = me + self.error = error + self.closed = False + + def __call__(self, base_url, api_token, **kwargs): + self.base_url = base_url + self.api_token = api_token + self.kwargs = kwargs + return self + + async def get_current_user(self): + if self.error is not None: + raise self.error + return self.me + + async def close(self): + self.closed = True + + +@pytest.fixture +def target_user(): + return user(7, "anna.smith", email="anna.smith@example.com") + + +@pytest.mark.asyncio +async def test_provision_token_returns_verified_token(monkeypatch, target_user): + admin = FakeAdminClient( + AccessTokenCreated(id=1, name="Kimai MCP (auto)", token="secret-token") + ) + probe = FakeProbeClient(me=target_user) + monkeypatch.setattr(provisioning, "KimaiClient", probe) + + token = await provision_token( + admin, KIMAI_URL, target_user, token_name="Kimai MCP (auto)" + ) + + assert token == "secret-token" + assert admin.calls == [ + {"user_id": 7, "name": "Kimai MCP (auto)", "replace_existing": True} + ] + assert probe.api_token == "secret-token" + assert probe.closed + + +@pytest.mark.asyncio +async def test_provision_token_passes_ssl_verify_to_the_probe(monkeypatch, target_user): + admin = FakeAdminClient(AccessTokenCreated(id=1, name="n", token="tok")) + probe = FakeProbeClient(me=target_user) + monkeypatch.setattr(provisioning, "KimaiClient", probe) + + await provision_token(admin, KIMAI_URL, target_user, ssl_verify="/etc/ssl/corp.pem") + + assert probe.kwargs["ssl_verify"] == "/etc/ssl/corp.pem" + + +@pytest.mark.asyncio +async def test_provision_token_missing_plugin_returns_none(monkeypatch, target_user): + admin = FakeAdminClient(error=KimaiAPIError("Not Found", 404)) + monkeypatch.setattr(provisioning, "KimaiClient", FakeProbeClient(me=target_user)) + + assert await provision_token(admin, KIMAI_URL, target_user) is None + + +@pytest.mark.asyncio +async def test_provision_token_without_permission_returns_none(monkeypatch, target_user): + admin = FakeAdminClient(error=KimaiAPIError("Forbidden", 403)) + monkeypatch.setattr(provisioning, "KimaiClient", FakeProbeClient(me=target_user)) + + assert await provision_token(admin, KIMAI_URL, target_user) is None + + +@pytest.mark.asyncio +async def test_provision_token_discards_token_of_a_different_user(monkeypatch, target_user): + """The safety net: a token that resolves elsewhere is never handed out.""" + admin = FakeAdminClient(AccessTokenCreated(id=1, name="MCP", token="wrong-token")) + other = user(99, "someone.else") + monkeypatch.setattr(provisioning, "KimaiClient", FakeProbeClient(me=other)) + + assert await provision_token(admin, KIMAI_URL, target_user) is None + + +@pytest.mark.asyncio +async def test_provision_token_unverifiable_token_returns_none(monkeypatch, target_user): + admin = FakeAdminClient(AccessTokenCreated(id=1, name="MCP", token="tok")) + probe = FakeProbeClient(error=KimaiAPIError("Unauthorized", 401)) + monkeypatch.setattr(provisioning, "KimaiClient", probe) + + assert await provision_token(admin, KIMAI_URL, target_user) is None + assert probe.closed + + +# --------------------------------------------------------------------------- +# Slug generation +# --------------------------------------------------------------------------- + + +def test_generated_slug_is_url_safe_and_not_low_entropy(): + from kimai_mcp.streamable_http_server import is_low_entropy_slug + + slug = provisioning.KimaiProvisioner.generate_slug() + assert SLUG_PATTERN.match(slug) + assert not is_low_entropy_slug(slug) + + +def test_generated_slug_avoids_taken_ones(): + taken = {provisioning.KimaiProvisioner.generate_slug() for _ in range(5)} + assert provisioning.KimaiProvisioner.generate_slug(taken) not in taken + + +# --------------------------------------------------------------------------- +# ProvisionedUserStore +# --------------------------------------------------------------------------- + + +def _config(token: str = "tok") -> UserConfig: + return UserConfig(kimai_url=KIMAI_URL, kimai_token=token, oidc_identity="anna@example.com") + + +def test_store_round_trip(tmp_path): + path = tmp_path / "provisioned.json" + store = ProvisionedUserStore(path) + store.add("Anna@Example.com", "sLuG-1", _config()) + + users = UsersConfig() + assert store.load_into(users) == 1 + slug, config = users.get_user_by_oidc_identity("anna@example.com") + assert slug == "sLuG-1" + assert config.kimai_token == "tok" + # Identity is stored folded, so a differently cased login still matches. + assert json.loads(path.read_text())["anna@example.com"]["slug"] == "sLuG-1" + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes only") +def test_store_file_is_not_world_readable(tmp_path): + path = tmp_path / "provisioned.json" + ProvisionedUserStore(path).add("anna@example.com", "slug1", _config()) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_store_never_overrides_a_configured_user(tmp_path): + path = tmp_path / "provisioned.json" + ProvisionedUserStore(path).add("anna@example.com", "provisioned", _config("stale")) + + users = UsersConfig( + users={ + "handwritten": UserConfig( + kimai_url=KIMAI_URL, kimai_token="real", oidc_identity="anna@example.com" + ) + } + ) + assert ProvisionedUserStore(path).load_into(users) == 0 + slug, config = users.get_user_by_oidc_identity("anna@example.com") + assert slug == "handwritten" + assert config.kimai_token == "real" + + +def test_store_survives_a_corrupt_file(tmp_path): + path = tmp_path / "provisioned.json" + path.write_text("{ not json") + users = UsersConfig() + assert ProvisionedUserStore(path).load_into(users) == 0 + assert users.users == {} + + +def test_missing_store_file_is_not_an_error(tmp_path): + assert ProvisionedUserStore(tmp_path / "absent.json").load_into(UsersConfig()) == 0 + + +# --------------------------------------------------------------------------- +# KimaiProvisioner.provision() +# --------------------------------------------------------------------------- + + +class FakeProvisioningKimai: + """Serves as both the admin client and the probe built from the new token.""" + + calls: ClassVar[list[dict]] = [] + + def __init__(self, base_url, api_token, **kwargs): + self.api_token = api_token + + async def get_users(self, **kwargs): + return [user(7, "anna.smith", email="anna.smith@example.com")] + + async def create_api_token(self, user_id, name, replace_existing=True): + type(self).calls.append({"user_id": user_id, "name": name}) + return AccessTokenCreated(id=1, name=name, token=f"minted-{user_id}") + + async def get_current_user(self): + return user(7, "anna.smith", email="anna.smith@example.com") + + async def close(self): + pass + + +@pytest.mark.asyncio +async def test_parallel_sign_ins_mint_exactly_one_token(monkeypatch): + """Two callbacks racing for the same identity must not create two tokens.""" + import asyncio + + FakeProvisioningKimai.calls = [] + monkeypatch.setattr(provisioning, "KimaiClient", FakeProvisioningKimai) + + provisioner = provisioning.KimaiProvisioner( + provisioning.ProvisioningConfig(kimai_url=KIMAI_URL, admin_token="admin") + ) + users = UsersConfig() + claims = {"email": "anna.smith@example.com"} + + results = await asyncio.gather( + provisioner.provision("anna.smith@example.com", claims, users), + provisioner.provision("anna.smith@example.com", claims, users), + ) + + assert len(FakeProvisioningKimai.calls) == 1 + assert len(users.users) == 1 + assert results[0][0] == results[1][0] + + +# --------------------------------------------------------------------------- +# _build_provisioning_config() +# --------------------------------------------------------------------------- + + +def _args(**overrides): + import argparse + + defaults = { + "auto_provision": False, + "provision_kimai_url": None, + "provision_admin_token": None, + "provision_token_name": None, + "provision_match": None, + "provision_store": None, + "provision_ssl_verify": None, + } + return argparse.Namespace(**{**defaults, **overrides}) + + +def _oidc(): + from kimai_mcp.oidc import OIDCConfig + + return OIDCConfig(issuer="https://idp.test", client_id="cid") + + +def _build(args, oidc_config): + from kimai_mcp.streamable_http_server import _build_provisioning_config + + return _build_provisioning_config(args, oidc_config) + + +def test_provisioning_config_is_none_when_the_feature_is_off(monkeypatch): + monkeypatch.delenv("KIMAI_MCP_AUTO_PROVISION", raising=False) + assert _build(_args(), _oidc()) is None + + +def test_provisioning_requires_the_oidc_backend(monkeypatch): + monkeypatch.delenv("KIMAI_MCP_AUTO_PROVISION", raising=False) + with pytest.raises(ValueError, match="--auth-backend oidc"): + _build(_args(auto_provision=True), None) + + +def test_provisioning_names_the_flags_it_is_missing(monkeypatch): + monkeypatch.delenv("KIMAI_MCP_PROVISION_KIMAI_URL", raising=False) + monkeypatch.delenv("KIMAI_MCP_PROVISION_ADMIN_TOKEN", raising=False) + with pytest.raises(ValueError, match="--provision-") as excinfo: + _build(_args(auto_provision=True), _oidc()) + assert "--provision-kimai-url" in str(excinfo.value) + assert "--provision-admin-token" in str(excinfo.value) + + +def test_provisioning_config_from_env(monkeypatch): + monkeypatch.setenv("KIMAI_MCP_AUTO_PROVISION", "true") + monkeypatch.setenv("KIMAI_MCP_PROVISION_KIMAI_URL", KIMAI_URL + "/") + monkeypatch.setenv("KIMAI_MCP_PROVISION_ADMIN_TOKEN", "from-env") + monkeypatch.setenv("KIMAI_MCP_PROVISION_MATCH", "fuzzy") + + config = _build(_args(), _oidc()) + + assert config.kimai_url == KIMAI_URL + assert config.admin_token == "from-env" + assert config.match_mode == "fuzzy" + assert config.token_name == provisioning.DEFAULT_TOKEN_NAME + assert config.store_path is None + + +def test_cli_flags_win_over_the_environment(monkeypatch): + monkeypatch.setenv("KIMAI_MCP_PROVISION_ADMIN_TOKEN", "from-env") + config = _build( + _args( + auto_provision=True, + provision_kimai_url=KIMAI_URL, + provision_admin_token="from-cli", + ), + _oidc(), + ) + assert config.admin_token == "from-cli" From 83fc6b6b7d393689ffaae6c9d974044a0f215a30 Mon Sep 17 00:00:00 2001 From: Maximilian von Heyden Date: Thu, 13 Aug 2026 09:24:20 +0200 Subject: [PATCH 4/6] docs: document automatic onboarding README gets an "Automatic onboarding (optional)" section under the OIDC backend, the seven new flags in the CLI table, and the match-mode table with the warning that fuzzy matching is a heuristic. .env.server.example and docker-compose.yml grow commented blocks; CLAUDE.md gets the architecture entry and the users.json note that provisioned users are runtime-only without --provision-store. Co-Authored-By: Claude Opus 5 (1M context) --- .env.server.example | 33 +++++++++++++++++++++++++++++++ CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 7 ++++++- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 9 +++++++++ 5 files changed, 139 insertions(+), 1 deletion(-) diff --git a/.env.server.example b/.env.server.example index d47488b..d278c85 100644 --- a/.env.server.example +++ b/.env.server.example @@ -31,6 +31,39 @@ # Disable the deprecated /mcp/{slug} endpoints (recommended once OAuth works) # KIMAI_MCP_DISABLE_LEGACY_SLUGS=true +# ============================================================================= +# OIDC federated login (optional) +# ============================================================================= + +# KIMAI_MCP_AUTH_BACKEND=oidc +# KIMAI_MCP_OIDC_ISSUER=https://login.microsoftonline.com//v2.0 +# KIMAI_MCP_OIDC_CLIENT_ID= +# KIMAI_MCP_OIDC_CLIENT_SECRET= + +# ============================================================================= +# Automatic onboarding (optional; requires the OIDC backend) +# ============================================================================= + +# Resolve an unknown OIDC identity against Kimai and create that user's own API +# token at first sign-in instead of rejecting it. Needs the ApiTokenBundle +# plugin on the Kimai server - see kimai-plugin/ApiTokenBundle/README.md. +# KIMAI_MCP_AUTO_PROVISION=true +# KIMAI_MCP_PROVISION_KIMAI_URL=https://kimai.example.com + +# Kimai token of a user with 'api-token_other_profile' (ROLE_SUPER_ADMIN by +# default). Keep it out of the command line and out of version control. +# KIMAI_MCP_PROVISION_ADMIN_TOKEN=kimai-super-admin-api-token + +# exact | normalized (default) | fuzzy - read the README before using fuzzy +# KIMAI_MCP_PROVISION_MATCH=normalized + +# Name of the created tokens, as shown in the user's Kimai profile +# KIMAI_MCP_PROVISION_TOKEN_NAME=Kimai MCP (auto) + +# Keep provisioned users across restarts. Holds Kimai tokens in plaintext and is +# written with mode 0600; omit to keep them in memory only. +# KIMAI_MCP_PROVISION_STORE=/app/config/provisioned_users.json + # ============================================================================= # Security # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index 2423301..41a4a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Automatic Kimai onboarding for OIDC logins** (`--auto-provision`, `provisioning.py`). Until now + every user had to exist in `users.json` before they could sign in, together with an API token an + administrator first clicked together in Kimai's web UI — two manual steps before a new colleague + can use the connector at all. With the flag set, an OIDC identity that matches no configured user + is resolved against Kimai's own user list and Kimai mints that user's personal API token on the + spot, so signing in with the identity provider is the only step a user ever performs. + - Matching runs six rules from strongest to weakest and stops at the first that matches. **Every + rule must produce exactly one candidate**; a rule that hits several users aborts with + "ambiguous" instead of guessing, because a wrong match would hand one employee another + employee's token. The minted token is checked against `/api/users/me` and discarded if it + resolves to a different user — but that guards a wrong *token*, not a wrong *match*, which is + why the two name-based heuristics only run with `--provision-match fuzzy`. The default, + `normalized`, compares emails, usernames and address local parts with umlaut/diacritic folding + (`anna.vondorf` == `Anna von Dorf`); `exact` restricts it to full equality. + - **Off by default and strictly additive.** Every failure mode — no match, ambiguous match, + plugin missing, admin token without permission, Kimai unreachable — answers with the same + generic "not authorized" page the OIDC callback already returned, with the reason server-side + in the log only. Enabling it cannot change behaviour for a deployment that works today. + - Provisioned users live in memory, like the OAuth access and refresh tokens; `--provision-store + FILE` keeps them across restarts (plaintext tokens, written `0600`). Re-provisioning is + idempotent — tokens are replaced by name — so a restart without the store costs one Kimai call + at the next sign-in and leaves no dead tokens in the user's profile. A hand-written + `users.json` entry always wins over a stored one. + - Configuration mirrors the `--oidc-*` family: `--provision-kimai-url`, `--provision-admin-token`, + `--provision-token-name`, `--provision-match`, `--provision-store`, `--provision-ssl-verify`, + each with a `KIMAI_MCP_PROVISION_*` environment variable. A half-configured feature aborts at + startup instead of silently rejecting every first sign-in. +- **`kimai-plugin/ApiTokenBundle`** — a small Kimai plugin supplying the endpoint Kimai lacks: + `POST /api/users/{id}/api-token` (plus `GET` for metadata). Core Kimai can only *delete* tokens + through the API; creating one is a web-form action, so the alternative would have been driving an + admin web session through that HTML form. The plugin reuses Kimai's own `api-token` voter, i.e. + it grants nothing the Kimai UI would not — the calling token needs `api-token_other_profile` + (ROLE_SUPER_ADMIN by default). Requires Kimai 2.65+, is part of neither the Python package nor + the Docker image, and has no automated tests: this repository's CI has no PHP toolchain. +- `KimaiClient.create_api_token()` / `get_api_tokens()` and the `AccessTokenInfo` / + `AccessTokenCreated` models — the client side of that plugin. +- `UsersConfig.load(allow_empty=True)` and `UsersConfig.add_user()`. Without the first, a + provisioning-only deployment could not boot at all: both loaders and `initialize_users()` + insisted on at least one user existing before anybody had signed in. + ## [2.16.0] - 2026-08-11 Ports the server to MCP Python SDK 2.x, catches up with Kimai 2.62 - 2.65, and fixes a group of defects a review of the port surfaced. Several of them are long-standing and silent: the tool reported success while the field never reached Kimai. diff --git a/CLAUDE.md b/CLAUDE.md index 508b5d6..447bf66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ Notes: - The SSE server (`sse_server.py`, command `kimai-mcp-server`) was **removed in v2.16.0**. It had been non-functional since v2.12.0 (broken transport wiring, and the SSE transport is no longer part of the MCP specification). The SDK still ships `mcp.server.sse`, so this was dead code in this project, not a forced removal. - `--kimai-user` / `KIMAI_DEFAULT_USER` is deprecated: accepted but ignored (warning is logged). Use the `user_scope` parameter of the tools instead. - The streamable server serves an OAuth-protected `/mcp` endpoint (DCR + PKCE, login form at `/oauth/login` with user slug + `auth_secret`). The legacy `/mcp/{slug}` endpoints still work but are deprecated and can be disabled with `--disable-legacy-slugs`. -- `users.json` schema (see `src/kimai_mcp/user_config.py`): per slug `kimai_url`, `kimai_token`, optional `ssl_verify`, optional `auth_secret` (env override: `KIMAI_USER__AUTH_SECRET`). Slugs must match `^[a-zA-Z0-9_-]+$`; keys starting with `_` are comments. The former `kimai_user_id` field was removed and is ignored when present. +- `users.json` schema (see `src/kimai_mcp/user_config.py`): per slug `kimai_url`, `kimai_token`, optional `ssl_verify`, optional `auth_secret` (env override: `KIMAI_USER__AUTH_SECRET`), optional `oidc_identity`. Slugs must match `^[a-zA-Z0-9_-]+$`; keys starting with `_` are comments. The former `kimai_user_id` field was removed and is ignored when present. With `--auto-provision`, users that are *not* in this file are added at runtime and exist only in memory unless `--provision-store` is set. ## Releasing a New Version @@ -110,6 +110,11 @@ If PyPI deployment fails with "version already exists", the version numbers in t 3. **OAuth Provider (`oauth.py`)**: Embedded OAuth 2.1 authorization server (Dynamic Client Registration, mandatory PKCE S256, HTML login form at `/oauth/login` with user slug + `auth_secret`, opaque access tokens ~1h / refresh tokens ~30 days, in-memory token store, optional client persistence via state file). +3a. **Automatic provisioning (`provisioning.py`)**: Optional (`--auto-provision`, off by default). Resolves a verified OIDC identity to an existing Kimai user and has Kimai mint that user's personal API token, so a user never has to be pre-declared in `users.json`. Hooks into exactly one place — the `match is None` branch of `oauth.py::handle_oidc_callback` — and returns the same `(slug, UserConfig)` shape as `get_user_by_oidc_identity()`, so every failure mode falls through to the pre-existing generic 403. Needs the `kimai-plugin/ApiTokenBundle` plugin on the Kimai server (core Kimai can only *delete* access tokens via the API) and an admin token with `api-token_other_profile`. + - Matching runs strongest-rule-first and **aborts on ambiguity instead of guessing** — a wrong match hands one employee another employee's token. The `/api/users/me` check on the minted token guards against a wrong *token*, not a wrong *match*, which is why the two name-based heuristics are behind `--provision-match fuzzy`. + - Provisioned users are in-memory by default (like the OAuth tokens); `--provision-store FILE` persists them, `0600`, hand-written config always wins. + - `UsersConfig.load(allow_empty=True)` and the softened `initialize_users()` check exist for this feature: a "sign in and nothing else" deployment has no users until someone signs in. + 4. **User Configuration (`user_config.py`)**: Multi-user configuration (`users.json` or env vars) with slug validation and per-user `auth_secret` support. 5. **Kimai API Client (`client.py`)**: HTTP client wrapper using httpx for all Kimai API interactions. Handles authentication, request formatting, response parsing and auto-pagination for list endpoints. diff --git a/README.md b/README.md index 917bd8e..74f36da 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,45 @@ Register **`/oauth/oidc/callback`** as the redirect URI at your OIDC When mapping by `email`, the `id_token` must also assert `email_verified: true`, otherwise the email claim is ignored — so a provider that lets users self-assert an unverified address cannot impersonate a mapped user. For providers that do not emit `email_verified` but are trusted to only issue verified emails, pass `--oidc-allow-unverified-email` (or `KIMAI_MCP_OIDC_ALLOW_UNVERIFIED_EMAIL=true`). +#### Automatic onboarding (optional) + +The mapping above still has to be maintained by hand, and each entry needs an API token that an administrator first created in Kimai's web UI. With `--auto-provision`, an identity that matches no configured user is instead resolved against Kimai's own user list and given its own personal API token at first sign-in — after that, signing in with the IdP is the only step a user ever performs. + +```bash +kimai-mcp-streamable \ + --users-config ./config/users.json \ + --public-url https://mcp.example.com \ + --auth-backend oidc \ + --oidc-issuer https://login.microsoftonline.com//v2.0 \ + --oidc-client-id \ + --auto-provision \ + --provision-kimai-url https://kimai.example.com \ + --disable-legacy-slugs +# Prefer the env var for the admin token: KIMAI_MCP_PROVISION_ADMIN_TOKEN +``` + +**Requirements** + +* **The [`ApiTokenBundle`](kimai-plugin/ApiTokenBundle/README.md) plugin on your Kimai server.** Core Kimai can only *delete* access tokens through the API; creating one is a web-form action. The plugin adds `POST /api/users/{id}/api-token` behind Kimai's own permission check. Without it every first-time sign-in is rejected — the server probes for the plugin at startup and says so once. +* **An admin token** (`--provision-admin-token`) belonging to a user with `api-token_other_profile`, which is ROLE_SUPER_ADMIN in Kimai's default role mapping. +* `--auth-backend oidc`. There is no verified identity to resolve without a federated login. + +**How an identity is matched.** Rules run from strongest to weakest and stop at the first one that matches. A rule matching more than one Kimai user aborts instead of guessing — a wrong match would hand one employee another employee's token. `--provision-match` selects how far to go: + +| Mode | Rules | +| ---- | ----- | +| `exact` | Kimai `email` equals the identity; Kimai `username` equals the identity | +| `normalized` (default) | …plus `username` equals the address local part, plus a folded comparison of username/alias/email that makes `anna.vondorf` and `Anna von Dorf` compare equal | +| `fuzzy` | …plus the `name` / `given_name`+`family_name` claims against the Kimai alias, plus single name parts (`anna@` vs. `anna.vondorf@`) | + +The `fuzzy` rules are heuristics — enable them only if you know the shape of your directory. The minted token is verified against `/api/users/me` and discarded if it resolves to a different user, but that guards against a wrong *token*, not a wrong *match*. + +Whatever prevents an onboarding — no match, ambiguous match, plugin missing, missing permission, Kimai unreachable — the response is the same generic "not authorized" page as before, with the actual reason in the server log only. The feature is off by default and cannot change behaviour for an existing deployment. + +**Persistence.** Provisioned users live in memory by default, like the OAuth access and refresh tokens: after a restart the next sign-in re-provisions them, which is idempotent (tokens are replaced by name, so nothing piles up in the Kimai profile). Pass `--provision-store FILE` to keep them across restarts; that file holds Kimai API tokens in plaintext and is written with mode `0600`. A hand-written `users.json` entry always wins over a stored one. + +**Slugs.** Provisioned users get a random slug of the same strength as the ones `users.example.json` tells you to generate, and no `auth_secret`, so the local login form cannot be used for them. The slug alone is a credential on the deprecated `/mcp/{slug}` routes, so run auto-provisioning with `--disable-legacy-slugs`; the server warns at startup when both are active. + 📖 **[See full deployment guide →](DEPLOYMENT.md)** ## Command Line Options @@ -151,6 +190,14 @@ Options for the Streamable HTTP server (`kimai-mcp-streamable`): | `--oidc-scopes SCOPES` | `KIMAI_MCP_OIDC_SCOPES` | Requested scopes (default: `openid email profile`) | | `--oidc-identity-claim CLAIM` | `KIMAI_MCP_OIDC_IDENTITY_CLAIM` | id_token claim mapped to a user's `oidc_identity` (default: `email`) | | `--oidc-discovery-url URL` | `KIMAI_MCP_OIDC_DISCOVERY_URL` | Override the discovery URL (default: `/.well-known/openid-configuration`) | +| `--oidc-allow-unverified-email` | `KIMAI_MCP_OIDC_ALLOW_UNVERIFIED_EMAIL` | Accept the `email` claim without `email_verified: true` | +| `--auto-provision` | `KIMAI_MCP_AUTO_PROVISION` | Onboard unknown OIDC identities automatically (requires `--auth-backend oidc` and the ApiTokenBundle plugin) | +| `--provision-kimai-url URL` | `KIMAI_MCP_PROVISION_KIMAI_URL` | Kimai URL written into provisioned user configs (required for `--auto-provision`) | +| `--provision-admin-token TOKEN` | `KIMAI_MCP_PROVISION_ADMIN_TOKEN` | Admin token used to mint per-user tokens; needs `api-token_other_profile` (prefer the env var) | +| `--provision-token-name NAME` | `KIMAI_MCP_PROVISION_TOKEN_NAME` | Name of the created tokens as shown in the Kimai profile (default: `Kimai MCP (auto)`) | +| `--provision-match {exact,normalized,fuzzy}` | `KIMAI_MCP_PROVISION_MATCH` | How far to go when matching an identity to a Kimai user (default: `normalized`) | +| `--provision-store FILE` | `KIMAI_MCP_PROVISION_STORE` | Persist provisioned users across restarts (plaintext tokens, written `0600`) | +| `--provision-ssl-verify VALUE` | `KIMAI_MCP_PROVISION_SSL_VERIFY` | SSL verification for provisioning calls: `true`, `false` or a CA path | ## 🛠️ Available Tools diff --git a/docker-compose.yml b/docker-compose.yml index 302ca3b..19644e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,15 @@ services: # Set to true to disable the deprecated /mcp/{slug} endpoints - KIMAI_MCP_DISABLE_LEGACY_SLUGS=${KIMAI_MCP_DISABLE_LEGACY_SLUGS:-false} + # Automatic onboarding of OIDC identities (needs --auth-backend oidc and the + # ApiTokenBundle plugin on the Kimai server; see kimai-plugin/ApiTokenBundle). + # Uncomment to enable - the admin token needs 'api-token_other_profile'. + # - KIMAI_MCP_AUTO_PROVISION=${KIMAI_MCP_AUTO_PROVISION:-false} + # - KIMAI_MCP_PROVISION_KIMAI_URL=${KIMAI_MCP_PROVISION_KIMAI_URL:-} + # - KIMAI_MCP_PROVISION_ADMIN_TOKEN=${KIMAI_MCP_PROVISION_ADMIN_TOKEN:-} + # - KIMAI_MCP_PROVISION_MATCH=${KIMAI_MCP_PROVISION_MATCH:-normalized} + # - KIMAI_MCP_PROVISION_STORE=${KIMAI_MCP_PROVISION_STORE:-/app/config/provisioned_users.json} + # Ports ports: - "${SERVER_PORT:-8000}:8000" From 3223c37b00d3eb1b9f9282a2582ac922ed9c38a8 Mon Sep 17 00:00:00 2001 From: Maximilian von Heyden Date: Thu, 13 Aug 2026 10:05:33 +0200 Subject: [PATCH 5/6] fix: a single given name must not link two different people The normalized rule compared an address local part against usernames, display names and the local part of a possibly different mail domain. So "max@corp.example" matched a colleague whose Kimai alias is "Max", or whose address is "max@partner.example" - and since each produces exactly one candidate, the ambiguity guard never fired. Provisioning then minted that colleague's token and bound it to the newcomer's identity, which is the one outcome the whole module is built to prevent. This was reachable in the default match mode. The folded comparison now requires the address to decompose into at least two name parts. That keeps the case the rule exists for (anna.vondorf@ vs. the alias "Anna von Dorf") and drops the class that collides. Single-token addresses still reach the exact rules above it and the fuzzy tier below. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 +++++- README.md | 2 +- src/kimai_mcp/provisioning.py | 15 +++++++++++++- tests/test_provisioning.py | 39 +++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a4a39..0703e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 resolves to a different user — but that guards a wrong *token*, not a wrong *match*, which is why the two name-based heuristics only run with `--provision-match fuzzy`. The default, `normalized`, compares emails, usernames and address local parts with umlaut/diacritic folding - (`anna.vondorf` == `Anna von Dorf`); `exact` restricts it to full equality. + (`anna.vondorf` == `Anna von Dorf`); `exact` restricts it to full equality. The folded + comparison requires an address of at least two name parts, because a single given name is not + an identifier: `max@corp.example` must not be matched to a colleague whose Kimai alias is + `Max` or whose address is `max@partner.example`. Those produce exactly one candidate, so the + ambiguity guard cannot catch them — the rule itself has to refuse. - **Off by default and strictly additive.** Every failure mode — no match, ambiguous match, plugin missing, admin token without permission, Kimai unreachable — answers with the same generic "not authorized" page the OIDC callback already returned, with the reason server-side diff --git a/README.md b/README.md index 74f36da..0b4ea2c 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ kimai-mcp-streamable \ | Mode | Rules | | ---- | ----- | | `exact` | Kimai `email` equals the identity; Kimai `username` equals the identity | -| `normalized` (default) | …plus `username` equals the address local part, plus a folded comparison of username/alias/email that makes `anna.vondorf` and `Anna von Dorf` compare equal | +| `normalized` (default) | …plus `username` equals the address local part, plus a folded comparison of username/alias/email that makes `anna.vondorf` and `Anna von Dorf` compare equal. The folded comparison needs an address of at least two name parts, so `max@` is never matched against a colleague whose alias is `Max` or whose address is `max@` on another mail domain | | `fuzzy` | …plus the `name` / `given_name`+`family_name` claims against the Kimai alias, plus single name parts (`anna@` vs. `anna.vondorf@`) | The `fuzzy` rules are heuristics — enable them only if you know the shape of your directory. The minted token is verified against `/api/users/me` and discarded if it resolves to a different user, but that guards against a wrong *token*, not a wrong *match*. diff --git a/src/kimai_mcp/provisioning.py b/src/kimai_mcp/provisioning.py index 651e066..a819fbd 100644 --- a/src/kimai_mcp/provisioning.py +++ b/src/kimai_mcp/provisioning.py @@ -234,6 +234,17 @@ def user_keys(user: User) -> set[str]: keys.add(normalize(local_part(user.email))) return {k for k in keys if k} + # A single given name is not an identifier. The normalized rule compares an + # address local part against usernames, *display names* and the local part of + # a possibly different mail domain, so "max@corp.example" would match a + # colleague whose Kimai alias is "Max" or whose address is + # "max@partner.example" - one candidate each, so the ambiguity guard never + # fires and the wrong person's token gets minted. Requiring at least two name + # parts keeps the case this rule exists for (anna.vondorf@ vs. the alias + # "Anna von Dorf") and drops the class that collides. Single-token addresses + # still reach the exact rules above, and the fuzzy tier below. + norm_local_is_evidence = len(name_parts(idp_local)) >= 2 + all_rules: dict[str, list[User]] = { "email": [u for u in candidates if u.email and u.email.strip().lower() == idp_email], "username==identity": [ @@ -242,7 +253,9 @@ def user_keys(user: User) -> set[str]: "username==local-part": [ u for u in candidates if u.username.strip().lower() == idp_local ], - "normalized": [u for u in candidates if norm_local and norm_local in user_keys(u)], + "normalized": [ + u for u in candidates if norm_local_is_evidence and norm_local in user_keys(u) + ], "display-name": [ u for u in candidates diff --git a/tests/test_provisioning.py b/tests/test_provisioning.py index b99d5da..d804ec6 100644 --- a/tests/test_provisioning.py +++ b/tests/test_provisioning.py @@ -93,6 +93,45 @@ def test_matches_normalized_alias(): assert result.rule == "normalized" +def test_normalized_rule_ignores_a_single_given_name_across_mail_domains(): + """max@corp must not be matched to a colleague who is max@partner. + + Only one candidate exists, so the ambiguity guard cannot help here - the + rule itself has to refuse, or the wrong person's token gets minted. + """ + users = [user(9, "mschmidt", alias="Martin Schmidt", email="max@partner.example")] + result = resolve_kimai_user( + users, + "max@corp.example", + display_name="Max Mustermann", + given_name="Max", + family_name="Mustermann", + ) + assert not result.matched + assert result.reason == "not_found" + + +def test_normalized_rule_does_not_match_an_address_against_a_first_name_alias(): + users = [user(5, "k.lehmann", alias="Max")] + result = resolve_kimai_user(users, "max@corp.example") + assert not result.matched + + +def test_a_single_token_address_still_matches_an_exact_username(): + """Tightening the normalized rule must not close the exact path above it.""" + users = [user(4, "maximilian")] + result = resolve_kimai_user(users, "maximilian@example.com") + assert result.user.id == 4 + assert result.rule == "username==local-part" + + +def test_normalized_rule_still_folds_a_two_part_login(): + users = [user(4, "annasmith")] + result = resolve_kimai_user(users, "anna.smith@example.com") + assert result.user.id == 4 + assert result.rule == "normalized" + + def test_matches_display_name_claim(): users = [user(11, "as2", alias="Anna Smith")] result = resolve_kimai_user( From 9d70c2d1f3361720b04d60be1cf343d363123e83 Mon Sep 17 00:00:00 2001 From: Maximilian von Heyden Date: Thu, 13 Aug 2026 10:49:31 +0200 Subject: [PATCH 6/6] fix: durability of the store write, and a PHP boolean that lies Two defects a review of the fork this was extracted from surfaced, both of which apply here unchanged. The provisioned-user store never fsynced before the rename, so the rename could reach the disk before the data and a host crash would leave a correctly named but truncated file - exactly what the temp file exists to prevent. The plugin cast replaceExisting with (bool), and the string "false" casts to true. A client sending {"replaceExisting": "false"} got the opposite of what it asked for and had its existing token deleted. Co-Authored-By: Claude Opus 5 (1M context) --- kimai-plugin/ApiTokenBundle/API/ApiTokenController.php | 9 ++++++++- src/kimai_mcp/provisioning.py | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/kimai-plugin/ApiTokenBundle/API/ApiTokenController.php b/kimai-plugin/ApiTokenBundle/API/ApiTokenController.php index 26a946b..461ba10 100644 --- a/kimai-plugin/ApiTokenBundle/API/ApiTokenController.php +++ b/kimai-plugin/ApiTokenBundle/API/ApiTokenController.php @@ -119,7 +119,14 @@ public function createApiToken(User $profile, Request $request): Response $payload = $this->decodeBody($request); $name = $this->readName($payload); $expiresAt = $this->readExpiresAt($payload); - $replaceExisting = (bool) ($payload['replaceExisting'] ?? false); + // filter_var, not a (bool) cast: the string "false" casts to true, so a + // client sending {"replaceExisting": "false"} would get the opposite of + // what it asked for and have its existing token deleted. + $replaceExisting = filter_var( + $payload['replaceExisting'] ?? false, + \FILTER_VALIDATE_BOOL, + \FILTER_NULL_ON_FAILURE + ) ?? false; if ($replaceExisting) { foreach ($this->accessTokenRepository->findForUser($profile) as $existing) { diff --git a/src/kimai_mcp/provisioning.py b/src/kimai_mcp/provisioning.py index a819fbd..d308cc9 100644 --- a/src/kimai_mcp/provisioning.py +++ b/src/kimai_mcp/provisioning.py @@ -30,6 +30,7 @@ import asyncio import json import logging +import os import re import secrets import time @@ -461,6 +462,11 @@ def add(self, identity: str, slug: str, config: UserConfig) -> None: tmp_path = self.path.with_suffix(self.path.suffix + ".tmp") with tmp_path.open("w", encoding="utf-8") as f: json.dump(data, f, indent=2) + # Without the fsync the rename can reach the disk before the + # data does, so a host crash leaves a correctly named but + # truncated file - the outcome the temp file exists to prevent. + f.flush() + os.fsync(f.fileno()) # Set the mode before the rename so the file is never briefly # world-readable while it already holds tokens. tmp_path.chmod(0o600)