fix(astrolabe): fix OAuth flow and settings UI for hybrid mode

In hybrid mode (multi_user_basic + offline_access), users need BOTH:
- OAuth token for Astrolabe→MCP API calls
- App password for MCP→Nextcloud background sync

Changes:
- Personal.php: Pass correct oauthUrl pointing to Astrolabe's OAuth
  controller instead of MCP server's browser OAuth. Check both OAuth
  token AND app password status in hybrid mode.
- personal.php template: Show two-step workflow UI requiring both
  credentials before showing "Active" status. Each step shows
  completion badges.
- IdpTokenRefresher.php: Use http://localhost for internal token
  refresh requests (consistent with OAuthController). External URLs
  like localhost:8080 don't work from inside the container.

Fixes 401 errors when searching in Astrolabe with hybrid deployment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-01-15 16:14:00 +01:00
co-authored by Claude Opus 4.5
parent f16f852b23
commit c95459234b
3 changed files with 175 additions and 108 deletions
+11 -17
View File
@@ -38,25 +38,19 @@ class IdpTokenRefresher {
/** /**
* Get Nextcloud base URL for constructing internal OIDC endpoint URLs. * Get Nextcloud base URL for constructing internal OIDC endpoint URLs.
* *
* @return string Base URL (e.g., "https://nextcloud.example.com") * IMPORTANT: This method returns the INTERNAL URL for server-to-server
* requests within the container. External URLs (like localhost:8080) won't
* work from inside the container since localhost refers to the container itself.
*
* For internal requests, we always use http://localhost (port 80) since
* Nextcloud's web server is accessible at that address inside the container.
*
* @return string Base URL for internal requests (e.g., "http://localhost")
*/ */
private function getNextcloudBaseUrl(): string { private function getNextcloudBaseUrl(): string {
// Prefer explicit CLI URL override // For internal requests within the container, always use http://localhost
$baseUrl = $this->config->getSystemValue('overwrite.cli.url', ''); // The web server is accessible at port 80 inside the container.
// External URLs like http://localhost:8080 won't work from inside the container.
if (!empty($baseUrl)) {
return rtrim($baseUrl, '/');
}
// Fallback to first trusted domain with protocol
$trustedDomains = $this->config->getSystemValue('trusted_domains', []);
if (!empty($trustedDomains)) {
$protocol = $this->config->getSystemValue('overwriteprotocol', 'https');
return $protocol . '://' . $trustedDomains[0];
}
// Last resort: localhost (log warning)
$this->logger->warning('IdpTokenRefresher: No Nextcloud URL configured, using localhost fallback');
return 'http://localhost'; return 'http://localhost';
} }
+38 -50
View File
@@ -79,60 +79,48 @@ class Personal implements ISettings {
// Check if user has MCP OAuth token // Check if user has MCP OAuth token
$token = $this->tokenStorage->getUserToken($userId); $token = $this->tokenStorage->getUserToken($userId);
// For multi_user_basic mode with app password support, check if user has app password // For multi_user_basic mode with app password support (hybrid mode)
// User needs BOTH:
// 1. OAuth token for Astrolabe→MCP API calls (stored in McpTokenStorage)
// 2. App password for MCP→Nextcloud background sync
if ($authMode === 'multi_user_basic' && $supportsAppPasswords) { if ($authMode === 'multi_user_basic' && $supportsAppPasswords) {
// Check if user has already provided an app password // Check both credentials
$hasBackgroundAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId); $hasOAuthToken = ($token !== null && !$this->tokenStorage->isExpired($token));
$hasAppPassword = $this->tokenStorage->hasBackgroundSyncAccess($userId);
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
if (!$hasBackgroundAccess) { // OAuth URL for Astrolabe's own OAuth controller (NOT MCP server's browser OAuth)
// No app password yet - show app password entry form $oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
return new TemplateResponse(
Application::APP_ID,
'settings/personal',
[
'serverUrl' => $this->client->getPublicServerUrl(), // Changed from server_url to serverUrl
'serverStatus' => $serverStatus,
'auth_mode' => $authMode,
'authMode' => $authMode, // Add camelCase version for template
'supports_app_passwords' => $supportsAppPasswords,
'supportsAppPasswords' => $supportsAppPasswords, // Add camelCase version
'session' => null, // No session yet
'hasBackgroundAccess' => false, // FIXED: Add missing parameter
'backgroundAccessGranted' => false, // FIXED: Add missing parameter
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'hasToken' => false, // No OAuth token in multi_user_basic mode
'requesttoken' => \OCP\Util::callRegister(),
],
TemplateResponse::RENDER_AS_BLANK
);
} else {
// User has app password - show active status
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
$parameters = [ $parameters = [
'userId' => $userId, 'userId' => $userId,
'serverStatus' => $serverStatus, 'serverUrl' => $this->client->getPublicServerUrl(),
'session' => null, // No user session for app passwords 'serverStatus' => $serverStatus,
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false, 'auth_mode' => $authMode,
'backgroundAccessGranted' => true, // App password grants background access 'authMode' => $authMode,
'serverUrl' => $this->client->getPublicServerUrl(), 'supports_app_passwords' => $supportsAppPasswords,
'hasToken' => false, // No OAuth token 'supportsAppPasswords' => $supportsAppPasswords,
'hasBackgroundAccess' => true, 'session' => null, // No session in hybrid mode
'backgroundSyncType' => $backgroundSyncType, 'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt, // OAuth token status (for Astrolabe→MCP API calls)
'authMode' => $authMode, 'hasToken' => $hasOAuthToken,
'supportsAppPasswords' => $supportsAppPasswords, 'hasOAuthToken' => $hasOAuthToken,
'requesttoken' => \OCP\Util::callRegister(), 'oauthUrl' => $oauthUrl,
]; // App password status (for MCP→Nextcloud background sync)
'hasBackgroundAccess' => $hasAppPassword,
'backgroundAccessGranted' => $hasAppPassword,
'backgroundSyncType' => $backgroundSyncType,
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
'requesttoken' => \OCP\Util::callRegister(),
];
return new TemplateResponse( return new TemplateResponse(
Application::APP_ID, Application::APP_ID,
'settings/personal', 'settings/personal',
$parameters, $parameters,
TemplateResponse::RENDER_AS_BLANK TemplateResponse::RENDER_AS_BLANK
); );
}
} }
// For OAuth modes, if no token or token is expired, show OAuth authorization UI // For OAuth modes, if no token or token is expired, show OAuth authorization UI
elseif (!$token || $this->tokenStorage->isExpired($token)) { elseif (!$token || $this->tokenStorage->isExpired($token)) {
+126 -41
View File
@@ -43,7 +43,17 @@ style('astrolabe', 'astrolabe-personalSettings');
<div class="section"> <div class="section">
<h2><?php p($l->t('Background Sync Access')); ?></h2> <h2><?php p($l->t('Background Sync Access')); ?></h2>
<?php if ($_['hasBackgroundAccess'] || $_['backgroundAccessGranted']): ?> <?php
// In hybrid mode (multi_user_basic + app passwords), user needs BOTH OAuth AND app password
// to be "fully configured". Check both credentials in hybrid mode.
$isHybridMode = isset($_['authMode']) && $_['authMode'] === 'multi_user_basic' && !empty($_['supportsAppPasswords']);
$hasOAuthToken = !empty($_['hasOAuthToken']);
$hasBackgroundAccess = $_['hasBackgroundAccess'] || $_['backgroundAccessGranted'];
// In hybrid mode, both credentials required; otherwise just background access
$isFullyConfigured = $isHybridMode ? ($hasOAuthToken && $hasBackgroundAccess) : $hasBackgroundAccess;
?>
<?php if ($isFullyConfigured): ?>
<!-- Already configured --> <!-- Already configured -->
<div class="mcp-background-status"> <div class="mcp-background-status">
<p> <p>
@@ -110,54 +120,129 @@ style('astrolabe', 'astrolabe-personalSettings');
</div> </div>
<?php else: ?> <?php else: ?>
<!-- Not configured - show provisioning options --> <!-- Not configured - show provisioning options -->
<p class="mcp-help-text"> <?php if (isset($_['authMode']) && $_['authMode'] === 'multi_user_basic' && !empty($_['supportsAppPasswords'])): ?>
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?> <!-- Hybrid mode: User needs BOTH OAuth AND app password -->
</p>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?></h4>
<p class="mcp-help-text"> <p class="mcp-help-text">
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?> <?php p($l->t('To use semantic search, you need to complete two setup steps:')); ?>
</p>
<a href="<?php p($_['serverUrl']); ?>/oauth/login?next=<?php p(urlencode($urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])))); ?>" class="button">
<span class="icon icon-confirm"></span>
<?php p($l->t('Authorize via OAuth')); ?>
</a>
</div>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 2: App Password (Works Today - Recommended)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>
</p> </p>
<div class="mcp-app-password-steps"> <!-- Step 1: OAuth Authorization (for Astrolabe→MCP API calls) -->
<p><strong><?php p($l->t('Step 1:')); ?></strong> <div class="mcp-grant-section">
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank"> <h4>
<?php p($l->t('Generate app password in Security settings')); ?> <?php if (!empty($_['hasOAuthToken'])): ?>
<span class="badge badge-success"><span class="icon icon-checkmark-white"></span> <?php p($l->t('Complete')); ?></span>
<?php else: ?>
<span class="badge badge-warning"><?php p($l->t('Required')); ?></span>
<?php endif; ?>
<?php p($l->t('Step 1: Authorize Search Access')); ?>
</h4>
<p class="mcp-help-text">
<?php p($l->t('Authorize Astrolabe to perform searches on your behalf.')); ?>
</p>
<?php if (empty($_['hasOAuthToken'])): ?>
<a href="<?php p($_['oauthUrl']); ?>" class="button primary">
<span class="icon icon-confirm"></span>
<?php p($l->t('Authorize')); ?>
</a> </a>
<?php else: ?>
<p><span class="icon icon-checkmark"></span> <?php p($l->t('Search access authorized.')); ?></p>
<?php endif; ?>
</div>
<!-- Step 2: App Password (for MCP→Nextcloud background sync) -->
<div class="mcp-grant-section">
<h4>
<?php if (!empty($_['hasBackgroundAccess'])): ?>
<span class="badge badge-success"><span class="icon icon-checkmark-white"></span> <?php p($l->t('Complete')); ?></span>
<?php else: ?>
<span class="badge badge-warning"><?php p($l->t('Required')); ?></span>
<?php endif; ?>
<?php p($l->t('Step 2: Enable Background Indexing')); ?>
</h4>
<p class="mcp-help-text">
<?php p($l->t('Provide an app password to allow background indexing of your content.')); ?>
</p>
<?php if (empty($_['hasBackgroundAccess'])): ?>
<div class="mcp-app-password-steps">
<p>
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
<?php p($l->t('Generate app password in Security settings')); ?>
</a>
</p>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<div class="mcp-input-group">
<input type="password" name="appPassword" id="mcp-app-password-input"
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
required>
<button type="submit" class="button primary" id="mcp-save-app-password-button">
<span class="icon icon-checkmark"></span>
<?php p($l->t('Save')); ?>
</button>
</div>
<p class="mcp-help-text">
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
</p>
</form>
</div>
<?php else: ?>
<p><span class="icon icon-checkmark"></span> <?php p($l->t('Background indexing enabled.')); ?></p>
<?php endif; ?>
</div>
<?php else: ?>
<!-- Standard OAuth or BasicAuth mode -->
<p class="mcp-help-text">
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?>
</p>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>
</p>
<a href="<?php p($_['oauthUrl'] ?? ($_['serverUrl'] . '/oauth/login?next=' . urlencode($urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe']))))); ?>" class="button">
<span class="icon icon-confirm"></span>
<?php p($l->t('Authorize via OAuth')); ?>
</a>
</div>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 2: App Password (Works Today - Recommended)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>
</p> </p>
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p> <div class="mcp-app-password-steps">
<p><strong><?php p($l->t('Step 1:')); ?></strong>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form"> <a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>"> <?php p($l->t('Generate app password in Security settings')); ?>
<div class="mcp-input-group"> </a>
<input type="password" name="appPassword" id="mcp-app-password-input"
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
required>
<button type="submit" class="button primary" id="mcp-save-app-password-button">
<span class="icon icon-checkmark"></span>
<?php p($l->t('Save')); ?>
</button>
</div>
<p class="mcp-help-text">
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
</p> </p>
</form>
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<div class="mcp-input-group">
<input type="password" name="appPassword" id="mcp-app-password-input"
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
required>
<button type="submit" class="button primary" id="mcp-save-app-password-button">
<span class="icon icon-checkmark"></span>
<?php p($l->t('Save')); ?>
</button>
</div>
<p class="mcp-help-text">
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
</p>
</form>
</div>
</div> </div>
</div> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>