From 0f7e87a91cbf42647af97c1495b69d7c6d9eaac3 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 15 Dec 2025 21:39:44 +0100 Subject: [PATCH] feat(astrolabe): add OAuth token refresh and webhook presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- third_party/astroglobe/appinfo/routes.php | 22 + .../lib/Controller/ApiController.php | 418 +++++++++++++++++- .../lib/Controller/OAuthController.php | 96 ++-- .../lib/Service/IdpTokenRefresher.php | 130 ++++++ .../astroglobe/lib/Service/WebhookPresets.php | 188 ++++++++ 5 files changed, 825 insertions(+), 29 deletions(-) create mode 100644 third_party/astroglobe/lib/Service/IdpTokenRefresher.php create mode 100644 third_party/astroglobe/lib/Service/WebhookPresets.php diff --git a/third_party/astroglobe/appinfo/routes.php b/third_party/astroglobe/appinfo/routes.php index 3ecc74f..f9fb490 100644 --- a/third_party/astroglobe/appinfo/routes.php +++ b/third_party/astroglobe/appinfo/routes.php @@ -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', + ], ], ]; diff --git a/third_party/astroglobe/lib/Controller/ApiController.php b/third_party/astroglobe/lib/Controller/ApiController.php index 0c49ca1..c1e525e 100644 --- a/third_party/astroglobe/lib/Controller/ApiController.php +++ b/third_party/astroglobe/lib/Controller/ApiController.php @@ -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); + } } diff --git a/third_party/astroglobe/lib/Controller/OAuthController.php b/third_party/astroglobe/lib/Controller/OAuthController.php index 1e7b94d..b539126 100644 --- a/third_party/astroglobe/lib/Controller/OAuthController.php +++ b/third_party/astroglobe/lib/Controller/OAuthController.php @@ -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, [ diff --git a/third_party/astroglobe/lib/Service/IdpTokenRefresher.php b/third_party/astroglobe/lib/Service/IdpTokenRefresher.php new file mode 100644 index 0000000..6dda534 --- /dev/null +++ b/third_party/astroglobe/lib/Service/IdpTokenRefresher.php @@ -0,0 +1,130 @@ +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; + } + } +} diff --git a/third_party/astroglobe/lib/Service/WebhookPresets.php b/third_party/astroglobe/lib/Service/WebhookPresets.php new file mode 100644 index 0000000..e3200ff --- /dev/null +++ b/third_party/astroglobe/lib/Service/WebhookPresets.php @@ -0,0 +1,188 @@ + + * }> + */ + 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 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 $installedApps List of installed app names + * @return 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; + } +}