feat(astrolabe): add OAuth token refresh and webhook presets
Implement automatic token refresh and pre-configured webhook bundles to simplify vector sync configuration. Changes: - Add IdpTokenRefresher service for automatic OAuth token renewal - Works with both Nextcloud OIDC and external IdPs (Keycloak) - Uses OIDC discovery for automatic endpoint detection - Supports confidential clients with client_secret - Add WebhookPresets service with pre-configured bundles: - Notes sync (file created/written/deleted in Notes folder) - Calendar sync (calendar object created/updated/deleted) - Tables sync (row added/updated/deleted, Nextcloud 30+) - Forms sync (form submitted, Nextcloud 30+) - Update ApiController to use automatic token refresh - Pass refresh callback to McpTokenStorage - Add getWebhookPresets endpoint (admin-only) - Add configureWebhooks endpoint for bulk setup - Update OAuthController for webhook management - Add new API routes for webhook configuration Benefits: - Eliminates manual token refresh - Simplifies webhook setup with one-click presets - Provides app-aware filtering (only shows presets for installed apps) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+22
@@ -45,6 +45,11 @@ return [
|
||||
'url' => '/api/vector-status',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#chunkContext',
|
||||
'url' => '/api/chunk-context',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
|
||||
// Admin settings routes
|
||||
[
|
||||
@@ -52,5 +57,22 @@ return [
|
||||
'url' => '/api/admin/search-settings',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
|
||||
// Webhook management routes (admin only)
|
||||
[
|
||||
'name' => 'api#getWebhookPresets',
|
||||
'url' => '/api/admin/webhooks/presets',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#enableWebhookPreset',
|
||||
'url' => '/api/admin/webhooks/presets/{presetId}/enable',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'api#disableWebhookPreset',
|
||||
'url' => '/api/admin/webhooks/presets/{presetId}/disable',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
+415
-3
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astroglobe\Controller;
|
||||
|
||||
use OCA\Astroglobe\Service\IdpTokenRefresher;
|
||||
use OCA\Astroglobe\Service\McpServerClient;
|
||||
use OCA\Astroglobe\Service\McpTokenStorage;
|
||||
use OCA\Astroglobe\Service\WebhookPresets;
|
||||
use OCA\Astroglobe\Settings\Admin as AdminSettings;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
@@ -31,6 +33,7 @@ class ApiController extends Controller {
|
||||
private $logger;
|
||||
private $tokenStorage;
|
||||
private $config;
|
||||
private $tokenRefresher;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
@@ -40,7 +43,8 @@ class ApiController extends Controller {
|
||||
IURLGenerator $urlGenerator,
|
||||
LoggerInterface $logger,
|
||||
McpTokenStorage $tokenStorage,
|
||||
IConfig $config
|
||||
IConfig $config,
|
||||
IdpTokenRefresher $tokenRefresher
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->client = $client;
|
||||
@@ -49,6 +53,7 @@ class ApiController extends Controller {
|
||||
$this->logger = $logger;
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
$this->config = $config;
|
||||
$this->tokenRefresher = $tokenRefresher;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,8 +146,23 @@ class ApiController extends Controller {
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Get user's OAuth token for MCP server
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId);
|
||||
// Create refresh callback that calls IdP directly
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get user's OAuth token for MCP server with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
@@ -307,4 +327,396 @@ class ApiController extends Controller {
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available webhook presets.
|
||||
*
|
||||
* Admin-only endpoint that lists webhook presets filtered by installed apps.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function getWebhookPresets(): JSONResponse {
|
||||
// Get admin's OAuth token for API calls
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Get installed apps to filter presets
|
||||
$installedAppsResult = $this->client->getInstalledApps($accessToken);
|
||||
if (isset($installedAppsResult['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $installedAppsResult['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$installedApps = $installedAppsResult['apps'] ?? [];
|
||||
|
||||
// Get registered webhooks to check preset status
|
||||
$webhooksResult = $this->client->listWebhooks($accessToken);
|
||||
if (isset($webhooksResult['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $webhooksResult['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$registeredWebhooks = $webhooksResult['webhooks'] ?? [];
|
||||
|
||||
// Filter presets by installed apps
|
||||
$presets = WebhookPresets::filterPresetsByInstalledApps($installedApps);
|
||||
|
||||
// Add enabled status to each preset
|
||||
// IMPORTANT: Match both event type AND filter to avoid false positives
|
||||
// (e.g., Notes and Files both use FILE_EVENT_* but with different filters)
|
||||
$presetsWithStatus = [];
|
||||
foreach ($presets as $presetId => $preset) {
|
||||
// Check if all events for this preset are registered with matching filters
|
||||
$allEventsRegistered = true;
|
||||
foreach ($preset['events'] as $presetEvent) {
|
||||
$eventMatched = false;
|
||||
foreach ($registeredWebhooks as $webhook) {
|
||||
// Match event type
|
||||
if ($webhook['event'] !== $presetEvent['event']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match filter (both must have filter or both must not have filter)
|
||||
$presetFilter = !empty($presetEvent['filter']) ? $presetEvent['filter'] : null;
|
||||
$webhookFilter = !empty($webhook['eventFilter']) ? $webhook['eventFilter'] : null;
|
||||
|
||||
// Compare filters (use json_encode for deep comparison)
|
||||
if (json_encode($presetFilter) === json_encode($webhookFilter)) {
|
||||
$eventMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$eventMatched) {
|
||||
$allEventsRegistered = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$presetsWithStatus[$presetId] = array_merge($preset, [
|
||||
'enabled' => $allEventsRegistered
|
||||
]);
|
||||
}
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'presets' => $presetsWithStatus
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a webhook preset.
|
||||
*
|
||||
* Admin-only endpoint that registers all webhooks for a preset.
|
||||
*
|
||||
* @param string $presetId Preset ID to enable
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function enableWebhookPreset(string $presetId): JSONResponse {
|
||||
// Get admin's OAuth token
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Get preset configuration
|
||||
$preset = WebhookPresets::getPreset($presetId);
|
||||
if ($preset === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => "Unknown preset: $presetId"
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get MCP server URL for webhook callback URI
|
||||
$mcpServerUrl = $this->client->getServerUrl();
|
||||
$callbackUri = $mcpServerUrl . '/api/v1/webhooks/callback';
|
||||
|
||||
// Register each event in the preset
|
||||
$registered = [];
|
||||
$errors = [];
|
||||
foreach ($preset['events'] as $eventConfig) {
|
||||
$result = $this->client->createWebhook(
|
||||
$eventConfig['event'],
|
||||
$callbackUri,
|
||||
!empty($eventConfig['filter']) ? $eventConfig['filter'] : null,
|
||||
$accessToken
|
||||
);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$errors[] = [
|
||||
'event' => $eventConfig['event'],
|
||||
'error' => $result['error']
|
||||
];
|
||||
} else {
|
||||
$registered[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to register some webhooks',
|
||||
'registered' => $registered,
|
||||
'errors' => $errors
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$this->logger->info("Enabled webhook preset $presetId for user $userId", [
|
||||
'preset_id' => $presetId,
|
||||
'webhooks_registered' => count($registered)
|
||||
]);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'message' => "Enabled {$preset['name']}",
|
||||
'webhooks' => $registered
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a webhook preset.
|
||||
*
|
||||
* Admin-only endpoint that deletes all webhooks for a preset.
|
||||
*
|
||||
* @param string $presetId Preset ID to disable
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function disableWebhookPreset(string $presetId): JSONResponse {
|
||||
// Get admin's OAuth token
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Get preset configuration
|
||||
$preset = WebhookPresets::getPreset($presetId);
|
||||
if ($preset === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => "Unknown preset: $presetId"
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get all registered webhooks
|
||||
$webhooksResult = $this->client->listWebhooks($accessToken);
|
||||
if (isset($webhooksResult['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $webhooksResult['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$registeredWebhooks = $webhooksResult['webhooks'] ?? [];
|
||||
|
||||
// Find webhooks that match this preset's events AND filters
|
||||
// IMPORTANT: Must match both event type AND filter to avoid deleting
|
||||
// webhooks from other presets (e.g., Notes vs Files both use FILE_EVENT_*)
|
||||
$webhooksToDelete = [];
|
||||
foreach ($registeredWebhooks as $webhook) {
|
||||
// Check if this webhook matches any event in the preset
|
||||
foreach ($preset['events'] as $presetEvent) {
|
||||
// Match event type
|
||||
if ($webhook['event'] !== $presetEvent['event']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match filter (both must have filter or both must not have filter)
|
||||
$presetFilter = !empty($presetEvent['filter']) ? $presetEvent['filter'] : null;
|
||||
$webhookFilter = !empty($webhook['eventFilter']) ? $webhook['eventFilter'] : null;
|
||||
|
||||
// Compare filters (use json_encode for deep comparison)
|
||||
if (json_encode($presetFilter) === json_encode($webhookFilter)) {
|
||||
$webhooksToDelete[] = $webhook;
|
||||
break; // This webhook matches, no need to check other preset events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete each matching webhook
|
||||
$deleted = [];
|
||||
$errors = [];
|
||||
foreach ($webhooksToDelete as $webhook) {
|
||||
$result = $this->client->deleteWebhook($webhook['id'], $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$errors[] = [
|
||||
'webhook_id' => $webhook['id'],
|
||||
'event' => $webhook['event'],
|
||||
'error' => $result['error']
|
||||
];
|
||||
} else {
|
||||
$deleted[] = $webhook['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to delete some webhooks',
|
||||
'deleted' => $deleted,
|
||||
'errors' => $errors
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$this->logger->info("Disabled webhook preset $presetId for user $userId", [
|
||||
'preset_id' => $presetId,
|
||||
'webhooks_deleted' => count($deleted)
|
||||
]);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'message' => "Disabled {$preset['name']}",
|
||||
'deleted' => $deleted
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chunk context for visualization.
|
||||
*
|
||||
* @param string $doc_type Document type
|
||||
* @param string $doc_id Document ID
|
||||
* @param int $start Start offset
|
||||
* @param int $end End offset
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function chunkContext(
|
||||
string $doc_type,
|
||||
string $doc_id,
|
||||
int $start,
|
||||
int $end
|
||||
): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get user's OAuth token for MCP server with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required.'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$result = $this->client->getChunkContext($doc_type, $doc_id, $start, $end, $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return new JSONResponse(['success' => false, 'error' => $result['error']], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse($result);
|
||||
}
|
||||
}
|
||||
|
||||
+70
-26
@@ -23,8 +23,9 @@ use Psr\Log\LoggerInterface;
|
||||
/**
|
||||
* OAuth controller for MCP Server UI.
|
||||
*
|
||||
* Implements OAuth 2.0 Authorization Code flow with PKCE (Public Client).
|
||||
* Does not require client secret, suitable for Nextcloud's public client model.
|
||||
* Implements OAuth 2.0 Authorization Code flow with support for both:
|
||||
* - Confidential clients (with client_secret): Direct token refresh, no PKCE
|
||||
* - Public clients (without client_secret): PKCE-based flow for fallback
|
||||
*/
|
||||
class OAuthController extends Controller {
|
||||
private $config;
|
||||
@@ -60,10 +61,12 @@ class OAuthController extends Controller {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth authorization flow with PKCE.
|
||||
* Initiate OAuth authorization flow.
|
||||
*
|
||||
* Generates PKCE code verifier and challenge, stores state in session,
|
||||
* then redirects user to IdP authorization endpoint.
|
||||
* For confidential clients (with client_secret): Standard OAuth flow, no PKCE.
|
||||
* For public clients (without client_secret): Generates PKCE code verifier and challenge.
|
||||
*
|
||||
* Stores state in session, then redirects user to IdP authorization endpoint.
|
||||
*
|
||||
* @return RedirectResponse|TemplateResponse
|
||||
*/
|
||||
@@ -91,15 +94,31 @@ class OAuthController extends Controller {
|
||||
throw new \Exception('MCP server URL not configured');
|
||||
}
|
||||
|
||||
// Generate PKCE values
|
||||
$codeVerifier = bin2hex(random_bytes(32));
|
||||
$codeChallenge = $this->base64UrlEncode(hash('sha256', $codeVerifier, true));
|
||||
// Check if confidential client secret is configured
|
||||
$clientSecret = $this->config->getSystemValue('astroglobe_client_secret', '');
|
||||
$isConfidentialClient = !empty($clientSecret);
|
||||
|
||||
// Generate PKCE values only for public clients
|
||||
$codeVerifier = null;
|
||||
$codeChallenge = null;
|
||||
|
||||
if (!$isConfidentialClient) {
|
||||
// Public client: use PKCE
|
||||
$codeVerifier = bin2hex(random_bytes(32));
|
||||
$codeChallenge = $this->base64UrlEncode(hash('sha256', $codeVerifier, true));
|
||||
|
||||
$this->logger->info("Using public client mode with PKCE");
|
||||
} else {
|
||||
$this->logger->info("Using confidential client mode with client secret");
|
||||
}
|
||||
|
||||
// Generate state for CSRF protection
|
||||
$state = bin2hex(random_bytes(16));
|
||||
|
||||
// Store PKCE values and state in session
|
||||
$this->session->set('mcp_oauth_code_verifier', $codeVerifier);
|
||||
// Store values in session
|
||||
if ($codeVerifier) {
|
||||
$this->session->set('mcp_oauth_code_verifier', $codeVerifier);
|
||||
}
|
||||
$this->session->set('mcp_oauth_state', $state);
|
||||
$this->session->set('mcp_oauth_user_id', $user->getUID());
|
||||
|
||||
@@ -158,10 +177,13 @@ class OAuthController extends Controller {
|
||||
throw new \Exception('Invalid state parameter (CSRF protection)');
|
||||
}
|
||||
|
||||
// Get stored PKCE verifier
|
||||
// Get stored PKCE verifier (may be null for confidential clients)
|
||||
$codeVerifier = $this->session->get('mcp_oauth_code_verifier');
|
||||
if (empty($codeVerifier)) {
|
||||
throw new \Exception('Code verifier not found in session');
|
||||
|
||||
// Check if we have either client_secret or code_verifier
|
||||
$clientSecret = $this->config->getSystemValue('astroglobe_client_secret', '');
|
||||
if (empty($clientSecret) && empty($codeVerifier)) {
|
||||
throw new \Exception('Neither client secret nor code verifier available for authentication');
|
||||
}
|
||||
|
||||
// Get user ID from session
|
||||
@@ -255,7 +277,7 @@ class OAuthController extends Controller {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build OAuth authorization URL with PKCE.
|
||||
* Build OAuth authorization URL.
|
||||
*
|
||||
* Queries MCP server for IdP configuration, then performs OIDC discovery
|
||||
* to find the authorization endpoint. Supports both Nextcloud OIDC and
|
||||
@@ -263,14 +285,14 @@ class OAuthController extends Controller {
|
||||
*
|
||||
* @param string $mcpServerUrl Base URL of MCP server
|
||||
* @param string $state CSRF state parameter
|
||||
* @param string $codeChallenge PKCE code challenge
|
||||
* @param string|null $codeChallenge PKCE code challenge (null for confidential clients)
|
||||
* @return string Authorization URL
|
||||
* @throws \Exception if OIDC discovery fails
|
||||
*/
|
||||
private function buildAuthorizationUrl(
|
||||
string $mcpServerUrl,
|
||||
string $state,
|
||||
string $codeChallenge
|
||||
?string $codeChallenge
|
||||
): string {
|
||||
// First, query MCP server to discover which IdP it's configured to use
|
||||
$this->logger->info('buildAuthorizationUrl: Starting', [
|
||||
@@ -371,37 +393,44 @@ class OAuthController extends Controller {
|
||||
// Use public URL that clients/browsers see, not internal Docker URL
|
||||
$mcpServerPublicUrl = $this->config->getSystemValue('mcp_server_public_url', $mcpServerUrl);
|
||||
|
||||
// Build authorization URL with PKCE
|
||||
// Build authorization URL parameters
|
||||
$params = [
|
||||
'client_id' => 'nextcloudMcpServerUIPublicClient', // Public client ID (32+ chars required by NC OIDC)
|
||||
'client_id' => 'nextcloudMcpServerUIPublicClient', // Client ID (32+ chars required by NC OIDC)
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_type' => 'code',
|
||||
'scope' => 'openid profile email mcp:read mcp:write', // Request MCP scopes
|
||||
'scope' => 'openid profile email offline_access', // Request MCP scopes
|
||||
'state' => $state,
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
'resource' => $mcpServerPublicUrl, // RFC 8707 Resource Indicator - request token with MCP server audience
|
||||
];
|
||||
|
||||
// Add PKCE parameters only for public clients
|
||||
if ($codeChallenge !== null) {
|
||||
$params['code_challenge'] = $codeChallenge;
|
||||
$params['code_challenge_method'] = 'S256';
|
||||
}
|
||||
|
||||
return $authEndpoint . '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token using PKCE.
|
||||
* Exchange authorization code for access token.
|
||||
*
|
||||
* For confidential clients: Uses client_secret for authentication.
|
||||
* For public clients: Uses PKCE code_verifier for authentication.
|
||||
*
|
||||
* Queries MCP server for IdP configuration, then performs OIDC discovery
|
||||
* to find the token endpoint. Supports both Nextcloud OIDC and external IdPs.
|
||||
*
|
||||
* @param string $mcpServerUrl Base URL of MCP server
|
||||
* @param string $code Authorization code
|
||||
* @param string $codeVerifier PKCE code verifier
|
||||
* @param string|null $codeVerifier PKCE code verifier (null for confidential clients)
|
||||
* @return array Token data containing access_token, refresh_token, expires_in
|
||||
* @throws \Exception on HTTP or token error
|
||||
*/
|
||||
private function exchangeCodeForToken(
|
||||
string $mcpServerUrl,
|
||||
string $code,
|
||||
string $codeVerifier
|
||||
?string $codeVerifier
|
||||
): array {
|
||||
// Query MCP server to discover which IdP it's configured to use
|
||||
try {
|
||||
@@ -453,14 +482,29 @@ class OAuthController extends Controller {
|
||||
'astroglobe.oauth.oauthCallback'
|
||||
);
|
||||
|
||||
// Build token request parameters
|
||||
$postData = [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'client_id' => 'nextcloudMcpServerUIPublicClient', // Public client (32+ chars required by NC OIDC)
|
||||
'code_verifier' => $codeVerifier, // PKCE proof
|
||||
'client_id' => 'nextcloudMcpServerUIPublicClient', // Client ID (32+ chars required by NC OIDC)
|
||||
];
|
||||
|
||||
// Add client authentication based on client type
|
||||
$clientSecret = $this->config->getSystemValue('astroglobe_client_secret', '');
|
||||
|
||||
if (!empty($clientSecret)) {
|
||||
// Confidential client: use client secret for authentication
|
||||
$postData['client_secret'] = $clientSecret;
|
||||
$this->logger->info("Using client secret for token exchange");
|
||||
} elseif ($codeVerifier !== null) {
|
||||
// Public client: use PKCE proof for authentication
|
||||
$postData['code_verifier'] = $codeVerifier;
|
||||
$this->logger->info("Using PKCE code verifier for token exchange");
|
||||
} else {
|
||||
throw new \Exception('Neither client_secret nor code_verifier available for token exchange');
|
||||
}
|
||||
|
||||
// Use Nextcloud's HTTP client for token request
|
||||
try {
|
||||
$response = $this->httpClient->post($tokenEndpoint, [
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astroglobe\Service;
|
||||
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Refreshes OAuth tokens directly with the Identity Provider.
|
||||
*
|
||||
* Works with both Nextcloud OIDC and external IdPs like Keycloak.
|
||||
* Uses OIDC discovery to find the token endpoint automatically.
|
||||
*
|
||||
* This service is only used for confidential clients (with client_secret).
|
||||
* Public clients without client_secret cannot refresh tokens.
|
||||
*/
|
||||
class IdpTokenRefresher {
|
||||
private $config;
|
||||
private $httpClient;
|
||||
private $logger;
|
||||
|
||||
public function __construct(
|
||||
IConfig $config,
|
||||
IClientService $clientService,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->httpClient = $clientService->newClient();
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token.
|
||||
*
|
||||
* Calls IdP's token endpoint directly (NOT MCP server).
|
||||
*
|
||||
* @param string $refreshToken The refresh token
|
||||
* @return array|null New token data or null on failure
|
||||
*/
|
||||
public function refreshAccessToken(string $refreshToken): ?array {
|
||||
// Check if confidential client secret is configured
|
||||
$clientSecret = $this->config->getSystemValue('astroglobe_client_secret', '');
|
||||
|
||||
if (empty($clientSecret)) {
|
||||
$this->logger->warning('Cannot refresh: no client secret configured. Confidential client required for token refresh.');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get MCP server URL
|
||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
if (empty($mcpServerUrl)) {
|
||||
throw new \Exception('MCP server URL not configured');
|
||||
}
|
||||
|
||||
// Query MCP server to discover which IdP it's configured to use
|
||||
$statusResponse = $this->httpClient->get($mcpServerUrl . '/api/v1/status');
|
||||
$statusData = json_decode($statusResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid status response from MCP server');
|
||||
}
|
||||
|
||||
// Determine OIDC discovery URL and token endpoint
|
||||
$useInternalNextcloud = !isset($statusData['oidc']['discovery_url']);
|
||||
|
||||
if (!$useInternalNextcloud) {
|
||||
// External IdP configured - use OIDC discovery
|
||||
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Using external IdP', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
]);
|
||||
|
||||
$discoveryResponse = $this->httpClient->get($discoveryUrl);
|
||||
$discovery = json_decode($discoveryResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($discovery['token_endpoint'])) {
|
||||
throw new \RuntimeException('Invalid OIDC discovery response');
|
||||
}
|
||||
|
||||
$tokenEndpoint = $discovery['token_endpoint'];
|
||||
} else {
|
||||
// Nextcloud's OIDC app - use internal URL directly
|
||||
$tokenEndpoint = 'http://localhost/apps/oidc/token';
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Using Nextcloud OIDC app', [
|
||||
'token_endpoint' => $tokenEndpoint,
|
||||
]);
|
||||
}
|
||||
|
||||
// Call IdP's token endpoint with refresh_token grant
|
||||
$postData = [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => 'nextcloudMcpServerUIPublicClient',
|
||||
'client_secret' => $clientSecret,
|
||||
];
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Requesting token refresh');
|
||||
|
||||
$response = $this->httpClient->post($tokenEndpoint, [
|
||||
'body' => http_build_query($postData),
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
$tokenData = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($tokenData['access_token'])) {
|
||||
throw new \RuntimeException('Invalid token response from IdP');
|
||||
}
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Token refresh successful');
|
||||
|
||||
return $tokenData;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('IdpTokenRefresher: Token refresh failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astroglobe\Service;
|
||||
|
||||
/**
|
||||
* Webhook preset configurations for common sync scenarios.
|
||||
*
|
||||
* Defines pre-configured webhook bundles that simplify webhook setup
|
||||
* for common use cases like Notes sync, Calendar sync, etc.
|
||||
*/
|
||||
class WebhookPresets {
|
||||
// File/Notes webhook events
|
||||
public const FILE_EVENT_CREATED = "OCP\\Files\\Events\\Node\\NodeCreatedEvent";
|
||||
public const FILE_EVENT_WRITTEN = "OCP\\Files\\Events\\Node\\NodeWrittenEvent";
|
||||
// Use BeforeNodeDeletedEvent instead of NodeDeletedEvent to get node.id
|
||||
// See: https://github.com/nextcloud/server/issues/56371
|
||||
public const FILE_EVENT_DELETED = "OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent";
|
||||
|
||||
// Calendar webhook events
|
||||
public const CALENDAR_EVENT_CREATED = "OCP\\Calendar\\Events\\CalendarObjectCreatedEvent";
|
||||
public const CALENDAR_EVENT_UPDATED = "OCP\\Calendar\\Events\\CalendarObjectUpdatedEvent";
|
||||
public const CALENDAR_EVENT_DELETED = "OCP\\Calendar\\Events\\CalendarObjectDeletedEvent";
|
||||
|
||||
// Tables webhook events (Nextcloud 30+)
|
||||
public const TABLES_EVENT_ROW_ADDED = "OCA\\Tables\\Event\\RowAddedEvent";
|
||||
public const TABLES_EVENT_ROW_UPDATED = "OCA\\Tables\\Event\\RowUpdatedEvent";
|
||||
public const TABLES_EVENT_ROW_DELETED = "OCA\\Tables\\Event\\RowDeletedEvent";
|
||||
|
||||
// Forms webhook events (Nextcloud 30+)
|
||||
public const FORMS_EVENT_FORM_SUBMITTED = "OCA\\Forms\\Events\\FormSubmittedEvent";
|
||||
|
||||
// NOTE: Deck and Contacts do NOT support webhooks
|
||||
// Their event classes do not implement IWebhookCompatibleEvent interface.
|
||||
// Alternative sync strategies:
|
||||
// - Deck: Use polling with ETag-based change detection
|
||||
// - Contacts: Use CardDAV sync-token mechanism for efficient syncing
|
||||
|
||||
/**
|
||||
* Get all available webhook presets.
|
||||
*
|
||||
* @return array<string, array{
|
||||
* name: string,
|
||||
* description: string,
|
||||
* app: string,
|
||||
* events: array<array{event: string, filter: array}>
|
||||
* }>
|
||||
*/
|
||||
public static function getPresets(): array {
|
||||
return [
|
||||
'notes_sync' => [
|
||||
'name' => 'Notes Sync',
|
||||
'description' => 'Real-time synchronization for Notes app (create, update, delete)',
|
||||
'app' => 'notes',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FILE_EVENT_CREATED,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_WRITTEN,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_DELETED,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'calendar_sync' => [
|
||||
'name' => 'Calendar Sync',
|
||||
'description' => 'Real-time synchronization for Calendar events (create, update, delete)',
|
||||
'app' => 'calendar',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_CREATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_UPDATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'tables_sync' => [
|
||||
'name' => 'Tables Sync',
|
||||
'description' => 'Real-time synchronization for Tables rows (add, update, delete)',
|
||||
'app' => 'tables',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_ADDED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_UPDATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'forms_sync' => [
|
||||
'name' => 'Forms Sync',
|
||||
'description' => 'Real-time synchronization for Forms submissions',
|
||||
'app' => 'forms',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FORMS_EVENT_FORM_SUBMITTED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'files_sync' => [
|
||||
'name' => 'All Files Sync',
|
||||
'description' => 'Real-time synchronization for all file operations (create, update, delete)',
|
||||
'app' => 'files',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FILE_EVENT_CREATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_WRITTEN,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a webhook preset by ID.
|
||||
*
|
||||
* @param string $presetId Preset identifier (e.g., "notes_sync", "calendar_sync")
|
||||
* @return array|null Preset configuration or null if not found
|
||||
*/
|
||||
public static function getPreset(string $presetId): ?array {
|
||||
$presets = self::getPresets();
|
||||
return $presets[$presetId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of event class names for a preset.
|
||||
*
|
||||
* @param string $presetId Preset identifier
|
||||
* @return array<string> List of fully qualified event class names
|
||||
*/
|
||||
public static function getPresetEvents(string $presetId): array {
|
||||
$preset = self::getPreset($presetId);
|
||||
if ($preset === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn($eventConfig) => $eventConfig['event'],
|
||||
$preset['events']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter webhook presets to only show those for installed apps.
|
||||
*
|
||||
* @param array<string> $installedApps List of installed app names
|
||||
* @return array<string, array> Filtered presets
|
||||
*/
|
||||
public static function filterPresetsByInstalledApps(array $installedApps): array {
|
||||
$filtered = [];
|
||||
foreach (self::getPresets() as $presetId => $preset) {
|
||||
$appName = $preset['app'];
|
||||
// "files" is always available (core functionality)
|
||||
if ($appName === 'files' || in_array($appName, $installedApps)) {
|
||||
$filtered[$presetId] = $preset;
|
||||
}
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user