Files
nextcloud-mcp-server/third_party/astrolabe/lib/Controller/CredentialsController.php
T
Chris Coutinho 65c3f099fa feat(astrolabe): implement app password provisioning for multi-user background sync
Adds complete app password provisioning workflow for multi-user BasicAuth
deployments, allowing users to independently enable background sync by
generating and storing Nextcloud app passwords.

**New Components:**

Backend (PHP):
- CredentialsController: Validates and stores app passwords
  * Validates app password format and authenticity via OCS API
  * Stores encrypted passwords in oc_preferences
  * Provides status and credential management endpoints
- AstrolabeAdminSettings: Admin configuration page for MCP server URL
- AstrolabeAdminSettingsListener: Event listener for admin section
- Updated McpTokenStorage: Added background sync credential methods

Frontend:
- personalSettings.js: Form handling for app password entry
  * AJAX submission with error handling
  * Shows success/error notifications
  * Triggers page reload after successful save
- settings.css: Styling for settings pages
- Updated personal.php template: Two-option UI
  * Option 1: OAuth refresh token (future, not yet available)
  * Option 2: App password (works today, recommended)
  * Shows "Active" badge when provisioned
  * Displays credential type and provisioned timestamp

Routes:
- POST /api/v1/background-sync/credentials - Store app password
- GET /api/v1/background-sync/status - Get provisioning status
- DELETE /api/v1/background-sync/credentials - Revoke credentials
- GET /api/v1/background-sync/credentials/{userId} - Admin only

**Testing:**
- test_astrolabe_settings_buttons.py: Integration test for UI buttons

**Workflow:**
1. User generates app password in Nextcloud Security settings
2. User navigates to Astrolabe personal settings
3. User enters app password in "Option 2: App Password" form
4. Backend validates password via OCS API call
5. Password stored encrypted in oc_preferences
6. Page reloads showing "Active" badge with credential details
7. MCP server can now use stored password for background operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-22 19:39:13 +01:00

259 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Controller;
use OCA\Astrolabe\Service\McpServerClient;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* Controller for managing background sync credentials (app passwords).
*
* Handles storing and validating app passwords for multi-user BasicAuth mode.
*/
class CredentialsController extends Controller {
private $tokenStorage;
private $userSession;
private $logger;
private $config;
private $client;
private $httpClientService;
private $urlGenerator;
public function __construct(
string $appName,
IRequest $request,
McpTokenStorage $tokenStorage,
IUserSession $userSession,
LoggerInterface $logger,
IConfig $config,
McpServerClient $client,
IClientService $httpClientService,
IURLGenerator $urlGenerator,
) {
parent::__construct($appName, $request);
$this->tokenStorage = $tokenStorage;
$this->userSession = $userSession;
$this->logger = $logger;
$this->config = $config;
$this->client = $client;
$this->httpClientService = $httpClientService;
$this->urlGenerator = $urlGenerator;
}
/**
* Store app password for background sync.
*
* Validates the app password by making a test request to Nextcloud,
* then stores it encrypted if valid.
*
* @param string $appPassword Nextcloud app password
* @return JSONResponse
*/
#[NoAdminRequired]
public function storeAppPassword(string $appPassword): JSONResponse {
$user = $this->userSession->getUser();
if (!$user) {
$this->logger->error('storeAppPassword called without authenticated user');
return new JSONResponse([
'success' => false,
'error' => 'User not authenticated'
], Http::STATUS_UNAUTHORIZED);
}
$userId = $user->getUID();
// Validate app password format (xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
if (!preg_match('/^[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}$/', $appPassword)) {
$this->logger->warning("Invalid app password format for user: $userId");
return new JSONResponse([
'success' => false,
'error' => 'Invalid app password format'
], Http::STATUS_BAD_REQUEST);
}
// Validate app password with Nextcloud
$isValid = $this->validateAppPassword($userId, $appPassword);
if (!$isValid) {
$this->logger->warning("App password validation failed for user: $userId");
return new JSONResponse([
'success' => false,
'error' => 'Invalid app password. Please check the password and try again.'
], Http::STATUS_UNAUTHORIZED);
}
// Store encrypted app password
try {
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
$this->logger->info("Successfully stored app password for user: $userId");
return new JSONResponse([
'success' => true,
'message' => 'App password saved successfully'
], Http::STATUS_OK);
} catch (\Exception $e) {
$this->logger->error("Failed to store app password for user $userId", [
'error' => $e->getMessage()
]);
return new JSONResponse([
'success' => false,
'error' => 'Failed to save app password'
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Validate app password by making a test request to Nextcloud.
*
* @param string $userId User ID
* @param string $appPassword App password to validate
* @return bool True if valid, false otherwise
*/
private function validateAppPassword(string $userId, string $appPassword): bool {
try {
// Use 127.0.0.1 for internal validation (we're running inside Nextcloud container)
// Using IP address instead of 'localhost' to avoid Nextcloud's overwrite.cli.url rewriting
// getAbsoluteURL() returns the external URL which isn't accessible from inside the container
$baseUrl = 'http://127.0.0.1';
// Make a test request to Nextcloud API with BasicAuth
// Using OCS API user endpoint as a lightweight test
$testUrl = $baseUrl . '/ocs/v1.php/cloud/user?format=json';
$this->logger->debug("Validating app password for user: $userId against $testUrl");
// Use Nextcloud's HTTP client
$httpClient = $this->httpClientService->newClient();
$response = $httpClient->get($testUrl, [
'auth' => [$userId, $appPassword],
'headers' => [
'OCS-APIRequest' => 'true',
'Accept' => 'application/json',
],
'timeout' => 10,
]);
$statusCode = $response->getStatusCode();
// Success is 200 OK
if ($statusCode === 200) {
$this->logger->debug("App password validation successful for user: $userId");
return true;
}
$this->logger->warning("App password validation failed for user: $userId (HTTP $statusCode)");
return false;
} catch (\Exception $e) {
$this->logger->error("Exception during app password validation for user $userId", [
'error' => $e->getMessage()
]);
return false;
}
}
/**
* Get background sync credentials status for the current user.
*
* @return JSONResponse
*/
#[NoAdminRequired]
public function getStatus(): JSONResponse {
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse([
'success' => false,
'error' => 'User not authenticated'
], Http::STATUS_UNAUTHORIZED);
}
$userId = $user->getUID();
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
return new JSONResponse([
'success' => true,
'has_background_access' => $hasAccess,
'sync_type' => $syncType,
'provisioned_at' => $provisionedAt,
], Http::STATUS_OK);
}
/**
* Get credentials for a specific user (admin only).
*
* Note: This does NOT return the actual password, only metadata.
*
* @param string $userId User ID to check
* @return JSONResponse
*/
public function getCredentials(string $userId): JSONResponse {
// This endpoint should only be accessible by admins
// For now, just return metadata (not actual credentials)
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
return new JSONResponse([
'success' => true,
'user_id' => $userId,
'has_background_access' => $hasAccess,
'sync_type' => $syncType,
'provisioned_at' => $provisionedAt,
], Http::STATUS_OK);
}
/**
* Delete background sync credentials for the current user.
*
* @return JSONResponse
*/
#[NoAdminRequired]
public function deleteCredentials(): JSONResponse {
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse([
'success' => false,
'error' => 'User not authenticated'
], Http::STATUS_UNAUTHORIZED);
}
$userId = $user->getUID();
try {
// Delete both OAuth tokens and app password (if any exist)
$this->tokenStorage->deleteUserToken($userId);
$this->tokenStorage->deleteBackgroundSyncPassword($userId);
$this->logger->info("Deleted background sync credentials for user: $userId");
return new JSONResponse([
'success' => true,
'message' => 'Credentials deleted successfully'
], Http::STATUS_OK);
} catch (\Exception $e) {
$this->logger->error("Failed to delete credentials for user $userId", [
'error' => $e->getMessage()
]);
return new JSONResponse([
'success' => false,
'error' => 'Failed to delete credentials'
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}