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:
Chris Coutinho
2025-12-15 21:39:44 +01:00
parent 5acac804a1
commit 0f7e87a91c
5 changed files with 825 additions and 29 deletions
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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, [
+130
View File
@@ -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;
}
}
}
+188
View File
@@ -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;
}
}