fix(auth): Store app passwords locally for multi-user BasicAuth background sync

Previously, the multi-user BasicAuth mode attempted to retrieve app passwords
via OAuth client_credentials grant, which Nextcloud OIDC doesn't support.

This fix implements local storage for app passwords:
- Add app_passwords table via Alembic migration (002)
- Add store/get/delete methods to RefreshTokenStorage
- Add management API endpoints for app password provisioning:
  - POST /api/v1/users/{user_id}/app-password
  - GET /api/v1/users/{user_id}/app-password
  - DELETE /api/v1/users/{user_id}/app-password
- Update oauth_sync.py to read from local storage
- Update Astrolabe to send app passwords to MCP server after validation
- Add app-hook to configure mcp_server_url in Nextcloud

The flow is now:
1. User creates app password in Nextcloud Security settings
2. User enters it in Astrolabe Personal Settings
3. Astrolabe validates against Nextcloud, then sends to MCP server
4. MCP server stores encrypted app password locally
5. Background sync uses locally stored password

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-01-13 15:44:11 +01:00
parent 546f0c0674
commit e486e92f91
7 changed files with 691 additions and 32 deletions
@@ -94,24 +94,78 @@ class CredentialsController extends Controller {
], Http::STATUS_UNAUTHORIZED);
}
// Store encrypted app password
// Store encrypted app password locally in Nextcloud
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);
$this->logger->info("Stored app password locally for user: $userId");
} catch (\Exception $e) {
$this->logger->error("Failed to store app password for user $userId", [
$this->logger->error("Failed to store app password locally for user $userId", [
'error' => $e->getMessage()
]);
return new JSONResponse([
'success' => false,
'error' => 'Failed to save app password'
'error' => 'Failed to save app password locally'
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
// Send app password to MCP server for background sync
// Get MCP server URL from system config (set in config.php)
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
if (empty($mcpServerUrl)) {
$this->logger->warning("MCP server URL not configured, app password stored locally only");
return new JSONResponse([
'success' => true,
'message' => 'App password saved locally (MCP server not configured)'
], Http::STATUS_OK);
}
try {
$httpClient = $this->httpClientService->newClient();
// Send to MCP server with BasicAuth (user proves ownership of password)
$mcpEndpoint = rtrim($mcpServerUrl, '/') . '/api/v1/users/' . urlencode($userId) . '/app-password';
$this->logger->debug("Sending app password to MCP server: $mcpEndpoint");
$response = $httpClient->post($mcpEndpoint, [
'auth' => [$userId, $appPassword],
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'timeout' => 10,
]);
$statusCode = $response->getStatusCode();
$body = json_decode($response->getBody(), true);
if ($statusCode === 200 && ($body['success'] ?? false)) {
$this->logger->info("Successfully provisioned app password to MCP server for user: $userId");
return new JSONResponse([
'success' => true,
'message' => 'App password saved successfully'
], Http::STATUS_OK);
} else {
$error = $body['error'] ?? 'Unknown error';
$this->logger->error("MCP server rejected app password for user $userId: $error");
// Still return success since it was stored locally
return new JSONResponse([
'success' => true,
'message' => 'App password saved locally (MCP server sync failed)',
'warning' => $error
], Http::STATUS_OK);
}
} catch (\Exception $e) {
$this->logger->error("Failed to send app password to MCP server for user $userId", [
'error' => $e->getMessage()
]);
// Still return success since it was stored locally
return new JSONResponse([
'success' => true,
'message' => 'App password saved locally (MCP server unreachable)',
'warning' => $e->getMessage()
], Http::STATUS_OK);
}
}
/**