From da476639890b20f41d41764d41fa42e02dc34044 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Wed, 10 Jun 2026 23:32:41 +0530 Subject: [PATCH] fix: sanitize OAuth client store keys for URL-based client_ids Goose authenticates via a Client ID Metadata Document (CIMD), so its OAuth client_id is a URL (e.g. https://goose-docs.ai/oauth/client-metadata.json). FastMCP's OAuth proxy persists clients in the FileTreeStore keyed by client_id, and FileTreeStore defaults to PassthroughStrategy which does not sanitize keys. The slashes in the URL were treated as path separators, pointing at directories that were never created, causing a FileNotFoundError and a 500 on /authorize. Pass FileTreeV1KeySanitizationStrategy and FileTreeV1CollectionSanitizationStrategy to FileTreeStore so URL-shaped client_ids collapse to flat, filesystem-safe filenames. DCR clients (Claude, ChatGPT) were unaffected because their client_ids are opaque and slash-free. --- src/lampyrid/server.py | 22 +++++++++++++++++++--- tests/unit/test_server.py | 18 +++++++++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/lampyrid/server.py b/src/lampyrid/server.py index 4b1682c..fad1cb4 100644 --- a/src/lampyrid/server.py +++ b/src/lampyrid/server.py @@ -8,7 +8,11 @@ from fastmcp.server.auth.providers.google import GoogleProvider from fastmcp.utilities.logging import configure_logging from fastmcp.utilities.types import Image -from key_value.aio.stores.filetree import FileTreeStore +from key_value.aio.stores.filetree import ( + FileTreeStore, + FileTreeV1CollectionSanitizationStrategy, + FileTreeV1KeySanitizationStrategy, +) from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from mcp.types import Icon @@ -35,8 +39,20 @@ def _create_auth_provider() -> Optional[AuthProvider]: # Create storage directory if it doesn't exist settings.oauth_storage_path.mkdir(parents=True, exist_ok=True) - # Initialize file-tree storage with Fernet encryption - file_tree_store = FileTreeStore(data_directory=settings.oauth_storage_path) + # Initialize file-tree storage with Fernet encryption. + # Sanitize keys/collections so URL-based client_ids (e.g. Goose's CIMD + # client_id `https://.../client-metadata.json`) don't get interpreted as + # nested directory paths, which fails with FileNotFoundError. The default + # PassthroughStrategy leaves slashes untouched. + file_tree_store = FileTreeStore( + data_directory=settings.oauth_storage_path, + key_sanitization_strategy=FileTreeV1KeySanitizationStrategy( + directory=settings.oauth_storage_path + ), + collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy( + directory=settings.oauth_storage_path + ), + ) client_storage = FernetEncryptionWrapper( key_value=file_tree_store, fernet=Fernet(settings.oauth_storage_encryption_key), # ty:ignore[invalid-argument-type] diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index 7f568ef..39d0a4b 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -63,10 +63,16 @@ def test_create_auth_provider_with_persistence(self): with ( patch('lampyrid.server.GoogleProvider') as mock_google_provider, patch('lampyrid.server.FileTreeStore') as mock_file_tree_store, + patch('lampyrid.server.FileTreeV1KeySanitizationStrategy') as mock_key_strategy, + patch( + 'lampyrid.server.FileTreeV1CollectionSanitizationStrategy' + ) as mock_collection_strategy, patch('lampyrid.server.FernetEncryptionWrapper') as mock_encryption_wrapper, ): mock_google_provider.return_value = MagicMock() mock_file_tree_store.return_value = MagicMock() + mock_key_strategy.return_value = MagicMock() + mock_collection_strategy.return_value = MagicMock() mock_encryption_wrapper.return_value = MagicMock() result = _create_auth_provider() @@ -78,7 +84,17 @@ def test_create_auth_provider_with_persistence(self): ) # Verify file tree store and encryption wrapper were initialized mock_file_tree_store.assert_called_once_with( - data_directory=mock_settings.oauth_storage_path + data_directory=mock_settings.oauth_storage_path, + key_sanitization_strategy=mock_key_strategy.return_value, + collection_sanitization_strategy=mock_collection_strategy.return_value, + ) + # Sanitization strategies must be built against the storage path so that + # URL-based client_ids (Goose CIMD) don't break filesystem persistence + mock_key_strategy.assert_called_once_with( + directory=mock_settings.oauth_storage_path + ) + mock_collection_strategy.assert_called_once_with( + directory=mock_settings.oauth_storage_path ) mock_encryption_wrapper.assert_called_once() # Verify GoogleProvider was called with client_storage