From f16f852b239a083c6ec68ebfd3cf8073d54ba08b Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 15 Jan 2026 16:13:50 +0100 Subject: [PATCH 1/5] fix(api): return OIDC config in hybrid mode for Astrolabe OAuth flow The /api/v1/status endpoint now returns OIDC configuration (discovery_url, issuer) when running in hybrid mode (multi_user_basic + offline_access), not just in pure OAuth mode. This allows Astrolabe to discover the IdP and complete the OAuth flow for obtaining tokens to call MCP server management APIs. Co-Authored-By: Claude Opus 4.5 --- nextcloud_mcp_server/api/management.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/nextcloud_mcp_server/api/management.py b/nextcloud_mcp_server/api/management.py index 4922939..bfded9e 100644 --- a/nextcloud_mcp_server/api/management.py +++ b/nextcloud_mcp_server/api/management.py @@ -387,8 +387,13 @@ async def get_server_status(request: Request) -> JSONResponse: if mode == AuthMode.MULTI_USER_BASIC: response_data["supports_app_passwords"] = settings.enable_offline_access - # Include OIDC configuration if in OAuth mode - if auth_mode == "oauth": + # Include OIDC configuration if OAuth is available + # This includes OAuth mode AND hybrid mode (multi_user_basic + offline_access) + # Astrolabe needs OIDC config to discover IdP for OAuth flow in hybrid mode + oauth_provisioning_available = auth_mode == "oauth" or ( + mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access + ) + if oauth_provisioning_available: # Provide IdP discovery information for NC PHP app oidc_config = {} From c95459234b3c20f040aed54be8bed9d05e56ffba Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 15 Jan 2026 16:14:00 +0100 Subject: [PATCH 2/5] fix(astrolabe): fix OAuth flow and settings UI for hybrid mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In hybrid mode (multi_user_basic + offline_access), users need BOTH: - OAuth token for Astrolabe→MCP API calls - App password for MCP→Nextcloud background sync Changes: - Personal.php: Pass correct oauthUrl pointing to Astrolabe's OAuth controller instead of MCP server's browser OAuth. Check both OAuth token AND app password status in hybrid mode. - personal.php template: Show two-step workflow UI requiring both credentials before showing "Active" status. Each step shows completion badges. - IdpTokenRefresher.php: Use http://localhost for internal token refresh requests (consistent with OAuthController). External URLs like localhost:8080 don't work from inside the container. Fixes 401 errors when searching in Astrolabe with hybrid deployment. Co-Authored-By: Claude Opus 4.5 --- .../lib/Service/IdpTokenRefresher.php | 28 ++- .../astrolabe/lib/Settings/Personal.php | 88 ++++----- .../astrolabe/templates/settings/personal.php | 167 +++++++++++++----- 3 files changed, 175 insertions(+), 108 deletions(-) diff --git a/third_party/astrolabe/lib/Service/IdpTokenRefresher.php b/third_party/astrolabe/lib/Service/IdpTokenRefresher.php index d57e051..4ef5fab 100644 --- a/third_party/astrolabe/lib/Service/IdpTokenRefresher.php +++ b/third_party/astrolabe/lib/Service/IdpTokenRefresher.php @@ -38,25 +38,19 @@ class IdpTokenRefresher { /** * Get Nextcloud base URL for constructing internal OIDC endpoint URLs. * - * @return string Base URL (e.g., "https://nextcloud.example.com") + * IMPORTANT: This method returns the INTERNAL URL for server-to-server + * requests within the container. External URLs (like localhost:8080) won't + * work from inside the container since localhost refers to the container itself. + * + * For internal requests, we always use http://localhost (port 80) since + * Nextcloud's web server is accessible at that address inside the container. + * + * @return string Base URL for internal requests (e.g., "http://localhost") */ private function getNextcloudBaseUrl(): string { - // Prefer explicit CLI URL override - $baseUrl = $this->config->getSystemValue('overwrite.cli.url', ''); - - if (!empty($baseUrl)) { - return rtrim($baseUrl, '/'); - } - - // Fallback to first trusted domain with protocol - $trustedDomains = $this->config->getSystemValue('trusted_domains', []); - if (!empty($trustedDomains)) { - $protocol = $this->config->getSystemValue('overwriteprotocol', 'https'); - return $protocol . '://' . $trustedDomains[0]; - } - - // Last resort: localhost (log warning) - $this->logger->warning('IdpTokenRefresher: No Nextcloud URL configured, using localhost fallback'); + // For internal requests within the container, always use http://localhost + // The web server is accessible at port 80 inside the container. + // External URLs like http://localhost:8080 won't work from inside the container. return 'http://localhost'; } diff --git a/third_party/astrolabe/lib/Settings/Personal.php b/third_party/astrolabe/lib/Settings/Personal.php index 577f1e8..6a453b3 100644 --- a/third_party/astrolabe/lib/Settings/Personal.php +++ b/third_party/astrolabe/lib/Settings/Personal.php @@ -79,60 +79,48 @@ class Personal implements ISettings { // Check if user has MCP OAuth token $token = $this->tokenStorage->getUserToken($userId); - // For multi_user_basic mode with app password support, check if user has app password + // For multi_user_basic mode with app password support (hybrid mode) + // User needs BOTH: + // 1. OAuth token for Astrolabe→MCP API calls (stored in McpTokenStorage) + // 2. App password for MCP→Nextcloud background sync if ($authMode === 'multi_user_basic' && $supportsAppPasswords) { - // Check if user has already provided an app password - $hasBackgroundAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId); + // Check both credentials + $hasOAuthToken = ($token !== null && !$this->tokenStorage->isExpired($token)); + $hasAppPassword = $this->tokenStorage->hasBackgroundSyncAccess($userId); + $backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId); + $backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId); - if (!$hasBackgroundAccess) { - // No app password yet - show app password entry form - return new TemplateResponse( - Application::APP_ID, - 'settings/personal', - [ - 'serverUrl' => $this->client->getPublicServerUrl(), // Changed from server_url to serverUrl - 'serverStatus' => $serverStatus, - 'auth_mode' => $authMode, - 'authMode' => $authMode, // Add camelCase version for template - 'supports_app_passwords' => $supportsAppPasswords, - 'supportsAppPasswords' => $supportsAppPasswords, // Add camelCase version - 'session' => null, // No session yet - 'hasBackgroundAccess' => false, // FIXED: Add missing parameter - 'backgroundAccessGranted' => false, // FIXED: Add missing parameter - 'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false, - 'hasToken' => false, // No OAuth token in multi_user_basic mode - 'requesttoken' => \OCP\Util::callRegister(), - ], - TemplateResponse::RENDER_AS_BLANK - ); - } else { - // User has app password - show active status - $backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId); - $backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId); + // OAuth URL for Astrolabe's own OAuth controller (NOT MCP server's browser OAuth) + $oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth'); - $parameters = [ - 'userId' => $userId, - 'serverStatus' => $serverStatus, - 'session' => null, // No user session for app passwords - 'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false, - 'backgroundAccessGranted' => true, // App password grants background access - 'serverUrl' => $this->client->getPublicServerUrl(), - 'hasToken' => false, // No OAuth token - 'hasBackgroundAccess' => true, - 'backgroundSyncType' => $backgroundSyncType, - 'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt, - 'authMode' => $authMode, - 'supportsAppPasswords' => $supportsAppPasswords, - 'requesttoken' => \OCP\Util::callRegister(), - ]; + $parameters = [ + 'userId' => $userId, + 'serverUrl' => $this->client->getPublicServerUrl(), + 'serverStatus' => $serverStatus, + 'auth_mode' => $authMode, + 'authMode' => $authMode, + 'supports_app_passwords' => $supportsAppPasswords, + 'supportsAppPasswords' => $supportsAppPasswords, + 'session' => null, // No session in hybrid mode + 'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false, + // OAuth token status (for Astrolabe→MCP API calls) + 'hasToken' => $hasOAuthToken, + 'hasOAuthToken' => $hasOAuthToken, + 'oauthUrl' => $oauthUrl, + // App password status (for MCP→Nextcloud background sync) + 'hasBackgroundAccess' => $hasAppPassword, + 'backgroundAccessGranted' => $hasAppPassword, + 'backgroundSyncType' => $backgroundSyncType, + 'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt, + 'requesttoken' => \OCP\Util::callRegister(), + ]; - return new TemplateResponse( - Application::APP_ID, - 'settings/personal', - $parameters, - TemplateResponse::RENDER_AS_BLANK - ); - } + return new TemplateResponse( + Application::APP_ID, + 'settings/personal', + $parameters, + TemplateResponse::RENDER_AS_BLANK + ); } // For OAuth modes, if no token or token is expired, show OAuth authorization UI elseif (!$token || $this->tokenStorage->isExpired($token)) { diff --git a/third_party/astrolabe/templates/settings/personal.php b/third_party/astrolabe/templates/settings/personal.php index f4738be..2d4811d 100644 --- a/third_party/astrolabe/templates/settings/personal.php +++ b/third_party/astrolabe/templates/settings/personal.php @@ -43,7 +43,17 @@ style('astrolabe', 'astrolabe-personalSettings');

t('Background Sync Access')); ?>

- + +

@@ -110,54 +120,129 @@ style('astrolabe', 'astrolabe-personalSettings');

-

- t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?> -

- -
-

t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?>

+ +

- t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?> -

- - - t('Authorize via OAuth')); ?> - -
- -
-

t('Option 2: App Password (Works Today - Recommended)')); ?>

-

- t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?> + t('To use semantic search, you need to complete two setup steps:')); ?>

-
-

t('Step 1:')); ?> - - t('Generate app password in Security settings')); ?> + +

+ + +
+

+ + t('Complete')); ?> + + t('Required')); ?> + + t('Step 2: Enable Background Indexing')); ?> +

+

+ t('Provide an app password to allow background indexing of your content.')); ?> +

+ +
+

+ + t('Generate app password in Security settings')); ?> + +

+ +
+ +
+ + +
+

+ t('The app password will be validated and securely encrypted before storage.')); ?> +

+
+
+ +

t('Background indexing enabled.')); ?>

+ +
+ + + +

+ t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?> +

+ +
+

t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?>

+

+ t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?> +

+ + + t('Authorize via OAuth')); ?> + +
+ +
+

t('Option 2: App Password (Works Today - Recommended)')); ?>

+

+ t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>

-

t('Step 2:')); ?> t('Enter the app password below:')); ?>

- -
- -
- - -
-

- t('The app password will be validated and securely encrypted before storage.')); ?> +

+

t('Step 1:')); ?> + + t('Generate app password in Security settings')); ?> +

- + +

t('Step 2:')); ?> t('Enter the app password below:')); ?>

+ +
+ +
+ + +
+

+ t('The app password will be validated and securely encrypted before storage.')); ?> +

+
+
-
+
From e87ae56041da8ad7eb73c0ed8b95395a7c16b4bc Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 15 Jan 2026 17:21:22 +0100 Subject: [PATCH 3/5] fix(astrolabe): Fix NcSelect options and CSS loading - Use :input-label prop for NcSelect field labels instead of :label (the :label prop sets the option label property key, not the visible label) - Fix CSS loading in admin.php and personal.php templates to use astrolabe-main (the bundled CSS file) - Update minimum Nextcloud version to 31 (required for Vue 3) Co-Authored-By: Claude Opus 4.5 --- third_party/astrolabe/appinfo/info.xml | 2 +- third_party/astrolabe/src/components/admin/AdminSettings.vue | 4 ++-- third_party/astrolabe/templates/settings/admin.php | 2 +- third_party/astrolabe/templates/settings/personal.php | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/third_party/astrolabe/appinfo/info.xml b/third_party/astrolabe/appinfo/info.xml index c408ee3..0d03bac 100644 --- a/third_party/astrolabe/appinfo/info.xml +++ b/third_party/astrolabe/appinfo/info.xml @@ -40,7 +40,7 @@ See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for conf https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/01-unified-search-astrolabe.png?raw=1 https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/03-chunk-viewer-open.png?raw=1 - + OCA\Astrolabe\Settings\Personal diff --git a/third_party/astrolabe/src/components/admin/AdminSettings.vue b/third_party/astrolabe/src/components/admin/AdminSettings.vue index 53db507..396bc4b 100644 --- a/third_party/astrolabe/src/components/admin/AdminSettings.vue +++ b/third_party/astrolabe/src/components/admin/AdminSettings.vue @@ -154,7 +154,7 @@

@@ -164,7 +164,7 @@

diff --git a/third_party/astrolabe/templates/settings/admin.php b/third_party/astrolabe/templates/settings/admin.php index da80fd4..cbe1e79 100644 --- a/third_party/astrolabe/templates/settings/admin.php +++ b/third_party/astrolabe/templates/settings/admin.php @@ -7,7 +7,7 @@ */ script('astrolabe', 'astrolabe-adminSettings'); -style('astrolabe', 'astrolabe-adminSettings'); +style('astrolabe', 'astrolabe-main'); // All CSS bundled into main ?>

diff --git a/third_party/astrolabe/templates/settings/personal.php b/third_party/astrolabe/templates/settings/personal.php index 2d4811d..8cda195 100644 --- a/third_party/astrolabe/templates/settings/personal.php +++ b/third_party/astrolabe/templates/settings/personal.php @@ -18,7 +18,7 @@ $urlGenerator = \OC::$server->getURLGenerator(); script('astrolabe', 'astrolabe-personalSettings'); -style('astrolabe', 'astrolabe-personalSettings'); +style('astrolabe', 'astrolabe-main'); // All CSS bundled into main ?>
From 104a2ec9e333b8e750355f2794e5d0af04fd4e77 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 16 Jan 2026 10:43:59 +0100 Subject: [PATCH 4/5] test: Add unit tests for status endpoint OIDC config Add unit tests for /api/v1/status endpoint focusing on OIDC config: - Test hybrid mode (multi_user_basic + enable_offline_access) returns OIDC - Test pure multi_user_basic mode without offline_access omits OIDC - Test OAuth mode returns OIDC config - Test single-user BasicAuth mode omits OIDC config - Test partial OIDC config (only discovery_url or only issuer) Also updates docs/authentication.md with Astrolabe hybrid mode setup: - Two-step credential setup (OAuth + app password) - Technical details for each credential type - Request direction table explaining why two credentials needed Co-Authored-By: Claude Opus 4.5 --- docs/authentication.md | 49 +++ tests/unit/test_management_status_endpoint.py | 337 ++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 tests/unit/test_management_status_endpoint.py diff --git a/docs/authentication.md b/docs/authentication.md index 271b795..ef48fe8 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -223,6 +223,55 @@ NEXTCLOUD_OIDC_CLIENT_SECRET= | Token Storage | None | Refresh tokens only | All tokens | | Deployment Complexity | Low | Medium | High | +### Astrolabe User Setup (Hybrid Mode) + +When Astrolabe connects to an MCP server running in hybrid mode, users must complete a **two-step credential setup**: + +#### Step 1: OAuth Authorization (Search Access) + +**Purpose**: Allows Astrolabe to call MCP server APIs on the user's behalf. + +**Flow**: +1. User opens Astrolabe Personal Settings in Nextcloud +2. Clicks "Authorize" button +3. Redirected to Astrolabe's OAuth controller (`/apps/astrolabe/oauth/initiate`) +4. OAuth controller discovers IdP from MCP server's `/api/v1/status` endpoint +5. User authenticates with Identity Provider (Nextcloud OIDC or external IdP) +6. Tokens stored in Nextcloud user config (`McpTokenStorage`) +7. Astrolabe can now perform semantic searches via MCP API + +**Technical Details**: +- Token audience: MCP server +- Token storage: Nextcloud app config (`oc_preferences`) +- Used for: `/api/v1/search`, `/api/v1/status` (authenticated endpoints) + +#### Step 2: App Password (Background Indexing) + +**Purpose**: Allows MCP server to access Nextcloud content for background sync. + +**Flow**: +1. User generates app password in Nextcloud Security settings +2. Enters app password in Astrolabe Personal Settings +3. App password validated against Nextcloud and stored (encrypted) +4. MCP server can now index user's content in the background + +**Technical Details**: +- Credential type: Nextcloud app password +- Token storage: MCP server's refresh token database +- Used for: Background indexing, content sync to vector database + +#### Why Two Credentials? + +| Direction | Auth Method | Purpose | +|-----------|-------------|---------| +| Astrolabe → MCP Server | OAuth Bearer Token | User searches, settings management | +| MCP Server → Nextcloud | BasicAuth (App Password) | Background content indexing | + +The separation ensures: +- **Security**: Each credential has limited scope +- **Audit Trail**: OAuth tokens identify users; app passwords enable background ops +- **User Control**: Users explicitly grant each type of access + ### See Also - [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details - [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration diff --git a/tests/unit/test_management_status_endpoint.py b/tests/unit/test_management_status_endpoint.py new file mode 100644 index 0000000..11fff7e --- /dev/null +++ b/tests/unit/test_management_status_endpoint.py @@ -0,0 +1,337 @@ +""" +Unit tests for Management API status endpoint. + +Tests the /api/v1/status endpoint focusing on: +- OIDC config availability in different auth modes +- Hybrid mode (multi_user_basic + enable_offline_access) returning OIDC config +- OAuth mode returning OIDC config +- Non-OAuth modes NOT returning OIDC config +""" + +from unittest.mock import MagicMock, patch + +import pytest +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.testclient import TestClient + +from nextcloud_mcp_server.api.management import get_server_status +from nextcloud_mcp_server.config_validators import AuthMode + +pytestmark = pytest.mark.unit + + +def create_test_app(): + """Create a test Starlette app with the status endpoint.""" + return Starlette( + routes=[ + Route("/api/v1/status", get_server_status, methods=["GET"]), + ] + ) + + +def create_mock_settings( + enable_multi_user_basic: bool = False, + enable_offline_access: bool = False, + oidc_discovery_url: str | None = None, + oidc_issuer: str | None = None, + vector_sync_enabled: bool = False, + nextcloud_url: str = "http://localhost", + enable_token_exchange: bool = False, + mcp_client_id: str | None = None, + mcp_client_secret: str | None = None, +): + """Create mock settings with specified auth configuration.""" + settings = MagicMock() + settings.enable_multi_user_basic_auth = enable_multi_user_basic + settings.enable_offline_access = enable_offline_access + settings.oidc_discovery_url = oidc_discovery_url + settings.oidc_issuer = oidc_issuer + settings.vector_sync_enabled = vector_sync_enabled + settings.nextcloud_url = nextcloud_url + settings.enable_token_exchange = enable_token_exchange + settings.mcp_client_id = mcp_client_id + settings.mcp_client_secret = mcp_client_secret + return settings + + +class TestStatusEndpointOidcConfig: + """Tests for OIDC configuration in status endpoint.""" + + def test_hybrid_mode_returns_oidc_config(self): + """Test that hybrid mode (multi_user_basic + offline_access) returns OIDC config.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=True, + enable_offline_access=True, + oidc_discovery_url="http://keycloak/.well-known/openid-configuration", + oidc_issuer="http://keycloak/realms/test", + ) + + # get_settings and detect_auth_mode are imported inside the function + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.MULTI_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + # Verify auth mode + assert data["auth_mode"] == "multi_user_basic" + assert data["supports_app_passwords"] is True + + # Verify OIDC config is present (key feature for hybrid mode) + assert "oidc" in data + assert ( + data["oidc"]["discovery_url"] + == "http://keycloak/.well-known/openid-configuration" + ) + assert data["oidc"]["issuer"] == "http://keycloak/realms/test" + + def test_hybrid_mode_without_oidc_settings_no_oidc_key(self): + """Test that hybrid mode without OIDC settings doesn't include empty oidc key.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=True, + enable_offline_access=True, + oidc_discovery_url=None, + oidc_issuer=None, + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.MULTI_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + # OIDC key should NOT be present if no OIDC settings configured + assert "oidc" not in data + + def test_multi_user_basic_without_offline_access_no_oidc(self): + """Test that multi_user_basic WITHOUT offline_access doesn't return OIDC config.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=True, + enable_offline_access=False, # Key difference: no offline access + oidc_discovery_url="http://keycloak/.well-known/openid-configuration", + oidc_issuer="http://keycloak/realms/test", + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.MULTI_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + # Verify auth mode + assert data["auth_mode"] == "multi_user_basic" + assert data["supports_app_passwords"] is False + + # OIDC config should NOT be present (not hybrid mode) + assert "oidc" not in data + + def test_oauth_mode_returns_oidc_config(self): + """Test that OAuth mode returns OIDC config.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=False, + enable_offline_access=True, + oidc_discovery_url="http://nextcloud/.well-known/openid-configuration", + oidc_issuer="http://nextcloud", + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.OAUTH_SINGLE_AUDIENCE, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + # Verify auth mode + assert data["auth_mode"] == "oauth" + + # Verify OIDC config is present + assert "oidc" in data + assert ( + data["oidc"]["discovery_url"] + == "http://nextcloud/.well-known/openid-configuration" + ) + + def test_single_user_basic_no_oidc(self): + """Test that single-user BasicAuth mode doesn't return OIDC config.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=False, + enable_offline_access=False, + oidc_discovery_url="http://keycloak/.well-known/openid-configuration", + oidc_issuer="http://keycloak/realms/test", + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.SINGLE_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + # Verify auth mode + assert data["auth_mode"] == "basic" + + # OIDC config should NOT be present + assert "oidc" not in data + # supports_app_passwords should NOT be present (only for multi_user_basic) + assert "supports_app_passwords" not in data + + def test_oidc_partial_config_only_discovery_url(self): + """Test OIDC config with only discovery URL set.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=True, + enable_offline_access=True, + oidc_discovery_url="http://keycloak/.well-known/openid-configuration", + oidc_issuer=None, # Only discovery URL + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.MULTI_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + assert "oidc" in data + assert ( + data["oidc"]["discovery_url"] + == "http://keycloak/.well-known/openid-configuration" + ) + assert "issuer" not in data["oidc"] + + def test_oidc_partial_config_only_issuer(self): + """Test OIDC config with only issuer set.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=True, + enable_offline_access=True, + oidc_discovery_url=None, # Only issuer + oidc_issuer="http://keycloak/realms/test", + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.MULTI_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + assert "oidc" in data + assert "discovery_url" not in data["oidc"] + assert data["oidc"]["issuer"] == "http://keycloak/realms/test" + + +class TestStatusEndpointBasicResponse: + """Tests for basic status endpoint response fields.""" + + def test_status_includes_version(self): + """Test that status endpoint includes version.""" + mock_settings = create_mock_settings() + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.SINGLE_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + assert "version" in data + assert "uptime_seconds" in data + assert "management_api_version" in data + assert data["management_api_version"] == "1.0" + + def test_status_includes_vector_sync_enabled(self): + """Test that status endpoint includes vector_sync_enabled.""" + mock_settings = create_mock_settings(vector_sync_enabled=True) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.SINGLE_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + assert data["vector_sync_enabled"] is True From 1cc460b0d8927a6fe9c805cf9cb86e8b1b53862a Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 16 Jan 2026 10:44:52 +0100 Subject: [PATCH 5/5] fix(astrolabe): Address reviewer feedback for hybrid mode Addresses code review feedback: Personal.php: - Consolidate template variables to use camelCase consistently - Remove duplicate snake_case variables (auth_mode, supports_app_passwords) - Add oauthUrl to standard OAuth mode parameters (fixes fallback issue) - Add requesttoken for CSRF protection personal.php (template): - Use null coalescing for safe variable access - Reuse computed $isHybridMode variable instead of duplicate check - Remove complex fallback URL logic (oauthUrl now always provided) IdpTokenRefresher.php: - Use Nextcloud's overwrite.cli.url config when available - Fall back to http://localhost for container deployments - Better supports non-containerized environments Co-Authored-By: Claude Opus 4.5 --- .../lib/Service/IdpTokenRefresher.php | 20 +++++++++++-------- .../astrolabe/lib/Settings/Personal.php | 20 ++++++++++++------- .../astrolabe/templates/settings/personal.php | 14 ++++++------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/third_party/astrolabe/lib/Service/IdpTokenRefresher.php b/third_party/astrolabe/lib/Service/IdpTokenRefresher.php index 4ef5fab..f7faa4c 100644 --- a/third_party/astrolabe/lib/Service/IdpTokenRefresher.php +++ b/third_party/astrolabe/lib/Service/IdpTokenRefresher.php @@ -38,19 +38,23 @@ class IdpTokenRefresher { /** * Get Nextcloud base URL for constructing internal OIDC endpoint URLs. * - * IMPORTANT: This method returns the INTERNAL URL for server-to-server - * requests within the container. External URLs (like localhost:8080) won't - * work from inside the container since localhost refers to the container itself. + * Uses Nextcloud's CLI URL config if set (for non-containerized deployments), + * otherwise defaults to http://localhost for container environments. * - * For internal requests, we always use http://localhost (port 80) since - * Nextcloud's web server is accessible at that address inside the container. + * Configuration priority: + * 1. overwrite.cli.url - Official Nextcloud system config for CLI operations + * 2. http://localhost - Default for Docker containers (web server on port 80) * * @return string Base URL for internal requests (e.g., "http://localhost") */ private function getNextcloudBaseUrl(): string { - // For internal requests within the container, always use http://localhost - // The web server is accessible at port 80 inside the container. - // External URLs like http://localhost:8080 won't work from inside the container. + // Check for overwrite.cli.url (used in non-containerized deployments) + $cliUrl = $this->config->getSystemValue('overwrite.cli.url', ''); + if (!empty($cliUrl)) { + return rtrim($cliUrl, '/'); + } + + // Default: container environment with web server on localhost:80 return 'http://localhost'; } diff --git a/third_party/astrolabe/lib/Settings/Personal.php b/third_party/astrolabe/lib/Settings/Personal.php index 6a453b3..a93ca7f 100644 --- a/third_party/astrolabe/lib/Settings/Personal.php +++ b/third_party/astrolabe/lib/Settings/Personal.php @@ -93,23 +93,21 @@ class Personal implements ISettings { // OAuth URL for Astrolabe's own OAuth controller (NOT MCP server's browser OAuth) $oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth'); + // Consolidated template parameters (camelCase convention) $parameters = [ 'userId' => $userId, 'serverUrl' => $this->client->getPublicServerUrl(), 'serverStatus' => $serverStatus, - 'auth_mode' => $authMode, 'authMode' => $authMode, - 'supports_app_passwords' => $supportsAppPasswords, 'supportsAppPasswords' => $supportsAppPasswords, 'session' => null, // No session in hybrid mode 'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false, // OAuth token status (for Astrolabe→MCP API calls) - 'hasToken' => $hasOAuthToken, 'hasOAuthToken' => $hasOAuthToken, 'oauthUrl' => $oauthUrl, // App password status (for MCP→Nextcloud background sync) 'hasBackgroundAccess' => $hasAppPassword, - 'backgroundAccessGranted' => $hasAppPassword, + 'backgroundAccessGranted' => $hasAppPassword, // Legacy alias 'backgroundSyncType' => $backgroundSyncType, 'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt, 'requesttoken' => \OCP\Util::callRegister(), @@ -186,6 +184,9 @@ class Personal implements ISettings { $backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId); $backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId); + // OAuth URL for standard OAuth mode (in case user needs to re-authorize) + $oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth'); + // Provide initial state for Vue.js frontend (if needed) $this->initialState->provideInitialState('user-data', [ 'userId' => $userId, @@ -193,17 +194,22 @@ class Personal implements ISettings { 'session' => $userSession, ]); + // Consolidated template parameters (camelCase convention) $parameters = [ 'userId' => $userId, + 'serverUrl' => $this->client->getPublicServerUrl(), 'serverStatus' => $serverStatus, 'session' => $userSession, 'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false, - 'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false, - 'serverUrl' => $this->client->getPublicServerUrl(), - 'hasToken' => true, + // OAuth status + 'hasOAuthToken' => true, + 'oauthUrl' => $oauthUrl, + // Background sync status 'hasBackgroundAccess' => $hasBackgroundAccess, + 'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false, // Legacy 'backgroundSyncType' => $backgroundSyncType, 'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt, + 'requesttoken' => \OCP\Util::callRegister(), ]; return new TemplateResponse( diff --git a/third_party/astrolabe/templates/settings/personal.php b/third_party/astrolabe/templates/settings/personal.php index 8cda195..61db152 100644 --- a/third_party/astrolabe/templates/settings/personal.php +++ b/third_party/astrolabe/templates/settings/personal.php @@ -44,13 +44,13 @@ style('astrolabe', 'astrolabe-main'); // All CSS bundled into main

t('Background Sync Access')); ?>

@@ -120,7 +120,7 @@ style('astrolabe', 'astrolabe-main'); // All CSS bundled into main
- +

t('To use semantic search, you need to complete two setup steps:')); ?> @@ -203,7 +203,7 @@ style('astrolabe', 'astrolabe-main'); // All CSS bundled into main

t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>

- + t('Authorize via OAuth')); ?>