Merge pull request #483 from cbcoutinho/fix/astrolabe-oauth-hybrid-mode

fix(astrolabe): fix OAuth flow for hybrid mode
This commit is contained in:
Chris Coutinho
2026-01-16 10:53:45 +01:00
committed by GitHub
9 changed files with 584 additions and 116 deletions
+49
View File
@@ -223,6 +223,55 @@ NEXTCLOUD_OIDC_CLIENT_SECRET=<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
+7 -2
View File
@@ -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 = {}
@@ -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
+1 -1
View File
@@ -40,7 +40,7 @@ See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for conf
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/01-unified-search-astrolabe.png?raw=1</screenshot>
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/03-chunk-viewer-open.png?raw=1</screenshot>
<dependencies>
<nextcloud min-version="30" max-version="32"/>
<nextcloud min-version="31" max-version="32"/>
</dependencies>
<settings>
<personal>OCA\Astrolabe\Settings\Personal</personal>
+13 -15
View File
@@ -38,25 +38,23 @@ class IdpTokenRefresher {
/**
* Get Nextcloud base URL for constructing internal OIDC endpoint URLs.
*
* @return string Base URL (e.g., "https://nextcloud.example.com")
* Uses Nextcloud's CLI URL config if set (for non-containerized deployments),
* otherwise defaults to http://localhost for container environments.
*
* 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 {
// Prefer explicit CLI URL override
$baseUrl = $this->config->getSystemValue('overwrite.cli.url', '');
if (!empty($baseUrl)) {
return rtrim($baseUrl, '/');
// Check for overwrite.cli.url (used in non-containerized deployments)
$cliUrl = $this->config->getSystemValue('overwrite.cli.url', '');
if (!empty($cliUrl)) {
return rtrim($cliUrl, '/');
}
// 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');
// Default: container environment with web server on localhost:80
return 'http://localhost';
}
+47 -53
View File
@@ -79,60 +79,46 @@ 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(),
];
// Consolidated template parameters (camelCase convention)
$parameters = [
'userId' => $userId,
'serverUrl' => $this->client->getPublicServerUrl(),
'serverStatus' => $serverStatus,
'authMode' => $authMode,
'supportsAppPasswords' => $supportsAppPasswords,
'session' => null, // No session in hybrid mode
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
// OAuth token status (for Astrolabe→MCP API calls)
'hasOAuthToken' => $hasOAuthToken,
'oauthUrl' => $oauthUrl,
// App password status (for MCP→Nextcloud background sync)
'hasBackgroundAccess' => $hasAppPassword,
'backgroundAccessGranted' => $hasAppPassword, // Legacy alias
'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)) {
@@ -198,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,
@@ -205,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(
@@ -154,7 +154,7 @@
<NcSelect
:model-value="selectedAlgorithmOption"
:options="algorithmOptions"
:label="t('astrolabe', 'Search Algorithm')"
:input-label="t('astrolabe', 'Search Algorithm')"
class="form-field"
@update:model-value="settings.algorithm = $event ? $event.id : 'hybrid'" />
<p class="help-text">
@@ -164,7 +164,7 @@
<NcSelect
:model-value="selectedFusionOption"
:options="fusionOptions"
:label="t('astrolabe', 'Fusion Method')"
:input-label="t('astrolabe', 'Fusion Method')"
class="form-field"
@update:model-value="settings.fusion = $event ? $event.id : 'rrf'" />
<p class="help-text">
+1 -1
View File
@@ -7,7 +7,7 @@
*/
script('astrolabe', 'astrolabe-adminSettings');
style('astrolabe', 'astrolabe-adminSettings');
style('astrolabe', 'astrolabe-main'); // All CSS bundled into main
?>
<div id="astrolabe-admin-settings" class="section">
+127 -42
View File
@@ -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
?>
<div class="section">
@@ -43,7 +43,17 @@ style('astrolabe', 'astrolabe-personalSettings');
<div class="section">
<h2><?php p($l->t('Background Sync Access')); ?></h2>
<?php if ($_['hasBackgroundAccess'] || $_['backgroundAccessGranted']): ?>
<?php
// Determine if hybrid mode (multi_user_basic + app passwords)
// In hybrid mode, user needs BOTH OAuth AND app password to be "fully configured"
$isHybridMode = ($_['authMode'] ?? '') === 'multi_user_basic' && !empty($_['supportsAppPasswords']);
$hasOAuthToken = !empty($_['hasOAuthToken']);
$hasBackgroundAccess = !empty($_['hasBackgroundAccess']) || !empty($_['backgroundAccessGranted']);
// In hybrid mode: both credentials required; otherwise just background access
$isFullyConfigured = $isHybridMode ? ($hasOAuthToken && $hasBackgroundAccess) : $hasBackgroundAccess;
?>
<?php if ($isFullyConfigured): ?>
<!-- Already configured -->
<div class="mcp-background-status">
<p>
@@ -110,54 +120,129 @@ style('astrolabe', 'astrolabe-personalSettings');
</div>
<?php else: ?>
<!-- Not configured - show provisioning options -->
<p class="mcp-help-text">
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?>
</p>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?></h4>
<?php if ($isHybridMode): ?>
<!-- Hybrid mode: User needs BOTH OAuth AND app password -->
<p class="mcp-help-text">
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>
</p>
<a href="<?php p($_['serverUrl']); ?>/oauth/login?next=<?php p(urlencode($urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])))); ?>" class="button">
<span class="icon icon-confirm"></span>
<?php p($l->t('Authorize via OAuth')); ?>
</a>
</div>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 2: App Password (Works Today - Recommended)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>
<?php p($l->t('To use semantic search, you need to complete two setup steps:')); ?>
</p>
<div class="mcp-app-password-steps">
<p><strong><?php p($l->t('Step 1:')); ?></strong>
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
<?php p($l->t('Generate app password in Security settings')); ?>
<!-- Step 1: OAuth Authorization (for Astrolabe→MCP API calls) -->
<div class="mcp-grant-section">
<h4>
<?php if (!empty($_['hasOAuthToken'])): ?>
<span class="badge badge-success"><span class="icon icon-checkmark-white"></span> <?php p($l->t('Complete')); ?></span>
<?php else: ?>
<span class="badge badge-warning"><?php p($l->t('Required')); ?></span>
<?php endif; ?>
<?php p($l->t('Step 1: Authorize Search Access')); ?>
</h4>
<p class="mcp-help-text">
<?php p($l->t('Authorize Astrolabe to perform searches on your behalf.')); ?>
</p>
<?php if (empty($_['hasOAuthToken'])): ?>
<a href="<?php p($_['oauthUrl']); ?>" class="button primary">
<span class="icon icon-confirm"></span>
<?php p($l->t('Authorize')); ?>
</a>
<?php else: ?>
<p><span class="icon icon-checkmark"></span> <?php p($l->t('Search access authorized.')); ?></p>
<?php endif; ?>
</div>
<!-- Step 2: App Password (for MCP→Nextcloud background sync) -->
<div class="mcp-grant-section">
<h4>
<?php if (!empty($_['hasBackgroundAccess'])): ?>
<span class="badge badge-success"><span class="icon icon-checkmark-white"></span> <?php p($l->t('Complete')); ?></span>
<?php else: ?>
<span class="badge badge-warning"><?php p($l->t('Required')); ?></span>
<?php endif; ?>
<?php p($l->t('Step 2: Enable Background Indexing')); ?>
</h4>
<p class="mcp-help-text">
<?php p($l->t('Provide an app password to allow background indexing of your content.')); ?>
</p>
<?php if (empty($_['hasBackgroundAccess'])): ?>
<div class="mcp-app-password-steps">
<p>
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
<?php p($l->t('Generate app password in Security settings')); ?>
</a>
</p>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<div class="mcp-input-group">
<input type="password" name="appPassword" id="mcp-app-password-input"
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
required>
<button type="submit" class="button primary" id="mcp-save-app-password-button">
<span class="icon icon-checkmark"></span>
<?php p($l->t('Save')); ?>
</button>
</div>
<p class="mcp-help-text">
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
</p>
</form>
</div>
<?php else: ?>
<p><span class="icon icon-checkmark"></span> <?php p($l->t('Background indexing enabled.')); ?></p>
<?php endif; ?>
</div>
<?php else: ?>
<!-- Standard OAuth or BasicAuth mode -->
<p class="mcp-help-text">
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?>
</p>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>
</p>
<a href="<?php p($_['oauthUrl']); ?>" class="button">
<span class="icon icon-confirm"></span>
<?php p($l->t('Authorize via OAuth')); ?>
</a>
</div>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 2: App Password (Works Today - Recommended)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>
</p>
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<div class="mcp-input-group">
<input type="password" name="appPassword" id="mcp-app-password-input"
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
required>
<button type="submit" class="button primary" id="mcp-save-app-password-button">
<span class="icon icon-checkmark"></span>
<?php p($l->t('Save')); ?>
</button>
</div>
<p class="mcp-help-text">
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
<div class="mcp-app-password-steps">
<p><strong><?php p($l->t('Step 1:')); ?></strong>
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
<?php p($l->t('Generate app password in Security settings')); ?>
</a>
</p>
</form>
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<div class="mcp-input-group">
<input type="password" name="appPassword" id="mcp-app-password-input"
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
required>
<button type="submit" class="button primary" id="mcp-save-app-password-button">
<span class="icon icon-checkmark"></span>
<?php p($l->t('Save')); ?>
</button>
</div>
<p class="mcp-help-text">
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
</p>
</form>
</div>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>