f0ade4ad28
Add explicit property type declarations to IdpTokenRefresher, CredentialsController, OAuthController, and McpServerClient classes. This improves type safety and allows Psalm to properly infer types, eliminating MissingPropertyType and many MixedMethodCall errors. Also adds IClient import where needed and validates getSystemValue returns to ensure string types before use. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
551 lines
18 KiB
PHP
551 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\Astrolabe\Controller;
|
|
|
|
use OCA\Astrolabe\Service\McpServerClient;
|
|
use OCA\Astrolabe\Service\McpTokenStorage;
|
|
use OCP\AppFramework\Controller;
|
|
use OCP\AppFramework\Http;
|
|
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
|
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
|
use OCP\AppFramework\Http\RedirectResponse;
|
|
use OCP\AppFramework\Http\TemplateResponse;
|
|
use OCP\Http\Client\IClient;
|
|
use OCP\Http\Client\IClientService;
|
|
use OCP\IConfig;
|
|
use OCP\IL10N;
|
|
use OCP\IRequest;
|
|
use OCP\ISession;
|
|
use OCP\IURLGenerator;
|
|
use OCP\IUserSession;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
/**
|
|
* OAuth controller for MCP Server UI.
|
|
*
|
|
* Implements OAuth 2.0 Authorization Code flow with PKCE (RFC 9207).
|
|
* PKCE is always used for all clients (public and confidential) as recommended
|
|
* by modern OAuth 2.0 best practices.
|
|
*
|
|
* - Public clients: PKCE only
|
|
* - Confidential clients: PKCE + client_secret (defense in depth)
|
|
*/
|
|
class OAuthController extends Controller {
|
|
private IConfig $config;
|
|
private ISession $session;
|
|
private IUserSession $userSession;
|
|
private IURLGenerator $urlGenerator;
|
|
private McpTokenStorage $tokenStorage;
|
|
private LoggerInterface $logger;
|
|
private IL10N $l;
|
|
private IClient $httpClient;
|
|
private McpServerClient $client;
|
|
|
|
public function __construct(
|
|
string $appName,
|
|
IRequest $request,
|
|
IConfig $config,
|
|
ISession $session,
|
|
IUserSession $userSession,
|
|
IURLGenerator $urlGenerator,
|
|
McpTokenStorage $tokenStorage,
|
|
LoggerInterface $logger,
|
|
IL10N $l,
|
|
IClientService $clientService,
|
|
McpServerClient $client,
|
|
) {
|
|
parent::__construct($appName, $request);
|
|
$this->config = $config;
|
|
$this->session = $session;
|
|
$this->userSession = $userSession;
|
|
$this->urlGenerator = $urlGenerator;
|
|
$this->tokenStorage = $tokenStorage;
|
|
$this->logger = $logger;
|
|
$this->l = $l;
|
|
$this->httpClient = $clientService->newClient();
|
|
$this->client = $client;
|
|
}
|
|
|
|
/**
|
|
* Initiate OAuth authorization flow.
|
|
*
|
|
* Always generates PKCE code verifier and challenge (RFC 9207).
|
|
* Stores state and code verifier in session, then redirects user to IdP authorization endpoint.
|
|
*
|
|
* @return RedirectResponse|TemplateResponse
|
|
*/
|
|
#[NoAdminRequired]
|
|
#[NoCSRFRequired]
|
|
public function initiateOAuth() {
|
|
$this->logger->info('initiateOAuth called');
|
|
|
|
$user = $this->userSession->getUser();
|
|
if (!$user) {
|
|
$this->logger->error('initiateOAuth: User not authenticated');
|
|
return new TemplateResponse(
|
|
'astrolabe',
|
|
'settings/error',
|
|
['error' => $this->l->t('User not authenticated')]
|
|
);
|
|
}
|
|
|
|
$this->logger->info('initiateOAuth: User authenticated: ' . $user->getUID());
|
|
|
|
try {
|
|
// Get MCP server configuration
|
|
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
|
if (empty($mcpServerUrl)) {
|
|
throw new \Exception('MCP server URL not configured');
|
|
}
|
|
|
|
// Always generate PKCE values (RFC 9207: PKCE recommended for all clients)
|
|
$codeVerifier = bin2hex(random_bytes(32));
|
|
$codeChallenge = $this->base64UrlEncode(hash('sha256', $codeVerifier, true));
|
|
|
|
// Check if confidential client secret is also configured
|
|
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
|
$isConfidentialClient = !empty($clientSecret);
|
|
|
|
if ($isConfidentialClient) {
|
|
$this->logger->info('Using confidential client mode with PKCE and client secret');
|
|
} else {
|
|
$this->logger->info('Using public client mode with PKCE only');
|
|
}
|
|
|
|
// Generate state for CSRF protection
|
|
$state = bin2hex(random_bytes(16));
|
|
|
|
// Store values in session
|
|
$this->session->set('mcp_oauth_code_verifier', $codeVerifier);
|
|
$this->session->set('mcp_oauth_state', $state);
|
|
$this->session->set('mcp_oauth_user_id', $user->getUID());
|
|
|
|
// Build OAuth authorization URL
|
|
$authUrl = $this->buildAuthorizationUrl(
|
|
$mcpServerUrl,
|
|
$state,
|
|
$codeChallenge
|
|
);
|
|
|
|
$this->logger->info('Initiating OAuth flow for user: ' . $user->getUID());
|
|
|
|
return new RedirectResponse($authUrl);
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('Failed to initiate OAuth flow', [
|
|
'error' => $e->getMessage()
|
|
]);
|
|
|
|
return new TemplateResponse(
|
|
'astrolabe',
|
|
'settings/error',
|
|
['error' => $this->l->t('Failed to initiate OAuth: %s', [$e->getMessage()])]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle OAuth callback after user authorization.
|
|
*
|
|
* Validates state, exchanges authorization code for access token using PKCE,
|
|
* and stores tokens for the user.
|
|
*
|
|
* @param string $code Authorization code
|
|
* @param string $state State parameter for CSRF protection
|
|
* @param string|null $error Error from IdP
|
|
* @param string|null $error_description Error description from IdP
|
|
* @return RedirectResponse
|
|
*/
|
|
#[NoAdminRequired]
|
|
#[NoCSRFRequired]
|
|
public function oauthCallback(
|
|
string $code = '',
|
|
string $state = '',
|
|
?string $error = null,
|
|
?string $error_description = null,
|
|
): RedirectResponse {
|
|
try {
|
|
// Check for errors from IdP
|
|
if ($error) {
|
|
throw new \Exception("OAuth error: $error - " . ($error_description ?? ''));
|
|
}
|
|
|
|
// Validate state to prevent CSRF
|
|
$storedState = $this->session->get('mcp_oauth_state');
|
|
if (empty($storedState) || $state !== $storedState) {
|
|
throw new \Exception('Invalid state parameter (CSRF protection)');
|
|
}
|
|
|
|
// Get stored PKCE verifier (always required)
|
|
$codeVerifier = $this->session->get('mcp_oauth_code_verifier');
|
|
if (empty($codeVerifier)) {
|
|
throw new \Exception('PKCE code verifier not found in session');
|
|
}
|
|
|
|
// Get user ID from session
|
|
$userId = $this->session->get('mcp_oauth_user_id');
|
|
if (empty($userId)) {
|
|
throw new \Exception('User ID not found in session');
|
|
}
|
|
|
|
// Get MCP server configuration
|
|
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
|
if (empty($mcpServerUrl)) {
|
|
throw new \Exception('MCP server URL not configured');
|
|
}
|
|
|
|
// Exchange authorization code for tokens
|
|
$tokenData = $this->exchangeCodeForToken(
|
|
$mcpServerUrl,
|
|
$code,
|
|
$codeVerifier
|
|
);
|
|
|
|
// Store tokens for user
|
|
$this->tokenStorage->storeUserToken(
|
|
$userId,
|
|
$tokenData['access_token'],
|
|
$tokenData['refresh_token'] ?? '',
|
|
time() + ($tokenData['expires_in'] ?? 3600)
|
|
);
|
|
|
|
// Clean up session
|
|
$this->session->remove('mcp_oauth_code_verifier');
|
|
$this->session->remove('mcp_oauth_state');
|
|
$this->session->remove('mcp_oauth_user_id');
|
|
|
|
$this->logger->info("OAuth flow completed successfully for user: $userId");
|
|
|
|
// Redirect back to personal settings
|
|
return new RedirectResponse(
|
|
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
|
);
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('OAuth callback failed', [
|
|
'error' => $e->getMessage()
|
|
]);
|
|
|
|
// Clean up session
|
|
$this->session->remove('mcp_oauth_code_verifier');
|
|
$this->session->remove('mcp_oauth_state');
|
|
$this->session->remove('mcp_oauth_user_id');
|
|
|
|
// Redirect to settings with error
|
|
return new RedirectResponse(
|
|
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', [
|
|
'section' => 'astrolabe',
|
|
'error' => urlencode($e->getMessage())
|
|
])
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnect user's MCP OAuth tokens.
|
|
*
|
|
* Deletes stored tokens from Nextcloud. Note: Does not revoke tokens on IdP side.
|
|
*
|
|
* @return RedirectResponse
|
|
*/
|
|
#[NoAdminRequired]
|
|
public function disconnect(): RedirectResponse {
|
|
$user = $this->userSession->getUser();
|
|
if (!$user) {
|
|
return new RedirectResponse(
|
|
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
|
);
|
|
}
|
|
|
|
$userId = $user->getUID();
|
|
|
|
try {
|
|
$this->tokenStorage->deleteUserToken($userId);
|
|
$this->logger->info("Disconnected MCP OAuth for user: $userId");
|
|
} catch (\Exception $e) {
|
|
$this->logger->error("Failed to disconnect MCP OAuth for user $userId", [
|
|
'error' => $e->getMessage()
|
|
]);
|
|
}
|
|
|
|
return new RedirectResponse(
|
|
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build OAuth authorization URL.
|
|
*
|
|
* Queries MCP server for IdP configuration, then performs OIDC discovery
|
|
* to find the authorization endpoint. Supports both Nextcloud OIDC and
|
|
* external IdPs like Keycloak.
|
|
*
|
|
* Always uses PKCE (RFC 9207 recommends PKCE for all clients).
|
|
*
|
|
* @param string $mcpServerUrl Base URL of MCP server
|
|
* @param string $state CSRF state parameter
|
|
* @param string $codeChallenge PKCE code challenge
|
|
* @return string Authorization URL
|
|
* @throws \Exception if OIDC discovery fails
|
|
*/
|
|
private function buildAuthorizationUrl(
|
|
string $mcpServerUrl,
|
|
string $state,
|
|
string $codeChallenge,
|
|
): string {
|
|
// First, query MCP server to discover which IdP it's configured to use
|
|
$this->logger->info('buildAuthorizationUrl: Starting', [
|
|
'mcp_server_url' => $mcpServerUrl,
|
|
]);
|
|
|
|
try {
|
|
$statusUrl = $mcpServerUrl . '/api/v1/status';
|
|
$this->logger->info('buildAuthorizationUrl: Fetching MCP server status', [
|
|
'url' => $statusUrl,
|
|
]);
|
|
|
|
$statusResponse = $this->httpClient->get($statusUrl);
|
|
$statusData = json_decode($statusResponse->getBody(), true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new \RuntimeException('Invalid JSON in status response: ' . json_last_error_msg());
|
|
}
|
|
|
|
$this->logger->info('buildAuthorizationUrl: MCP server status received', [
|
|
'auth_mode' => $statusData['auth_mode'] ?? 'unknown',
|
|
'has_oidc' => isset($statusData['oidc']),
|
|
'oidc_discovery_url' => $statusData['oidc']['discovery_url'] ?? 'not_set',
|
|
]);
|
|
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('buildAuthorizationUrl: Failed to fetch MCP server status', [
|
|
'url' => $mcpServerUrl . '/api/v1/status',
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
throw new \Exception('Cannot connect to MCP server: ' . $e->getMessage());
|
|
}
|
|
|
|
// Determine OIDC discovery URL
|
|
// Priority: 1) MCP server's configured discovery URL, 2) Nextcloud OIDC app
|
|
if (isset($statusData['oidc']['discovery_url'])) {
|
|
// MCP server has external IdP configured (e.g., Keycloak)
|
|
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
|
$this->logger->info('Using IdP from MCP server configuration', [
|
|
'discovery_url' => $discoveryUrl,
|
|
]);
|
|
} else {
|
|
// Fall back to Nextcloud's OIDC app
|
|
// Use internal localhost URL for HTTP request (accessible from inside container)
|
|
// We'll transform the returned URLs to external format after discovery
|
|
$discoveryUrl = 'http://localhost/.well-known/openid-configuration';
|
|
$internalBaseUrl = 'http://localhost';
|
|
|
|
$this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [
|
|
'discovery_url' => $discoveryUrl,
|
|
]);
|
|
}
|
|
|
|
// Perform OIDC discovery
|
|
$this->logger->info('buildAuthorizationUrl: Starting OIDC discovery', [
|
|
'discovery_url' => $discoveryUrl,
|
|
]);
|
|
|
|
try {
|
|
$response = $this->httpClient->get($discoveryUrl);
|
|
$responseBody = $response->getBody();
|
|
$this->logger->info('buildAuthorizationUrl: Got OIDC discovery response', [
|
|
'status_code' => $response->getStatusCode(),
|
|
'body_length' => strlen($responseBody),
|
|
]);
|
|
|
|
$discovery = json_decode($responseBody, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new \RuntimeException('Invalid JSON in OIDC discovery: ' . json_last_error_msg());
|
|
}
|
|
|
|
if (!isset($discovery['authorization_endpoint'])) {
|
|
throw new \RuntimeException('Missing authorization_endpoint in OIDC discovery');
|
|
}
|
|
|
|
$authEndpoint = $discovery['authorization_endpoint'];
|
|
|
|
// Transform internal URL to external URL if using Nextcloud OIDC app
|
|
// The discovery was done via internal http://localhost but browsers need
|
|
// the external URL (e.g., http://localhost:8080)
|
|
if (isset($internalBaseUrl)) {
|
|
$externalBaseUrl = $this->urlGenerator->getAbsoluteURL('/');
|
|
$externalBaseUrl = rtrim($externalBaseUrl, '/');
|
|
$authEndpoint = str_replace($internalBaseUrl, $externalBaseUrl, $authEndpoint);
|
|
}
|
|
|
|
$this->logger->info('buildAuthorizationUrl: OIDC discovery succeeded', [
|
|
'auth_endpoint' => $authEndpoint,
|
|
'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',
|
|
]);
|
|
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('buildAuthorizationUrl: OIDC discovery failed', [
|
|
'discovery_url' => $discoveryUrl,
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
throw new \Exception('Failed to discover OAuth endpoints: ' . $e->getMessage());
|
|
}
|
|
|
|
// Build callback URL
|
|
$redirectUri = $this->urlGenerator->linkToRouteAbsolute(
|
|
'astrolabe.oauth.oauthCallback'
|
|
);
|
|
|
|
// Get public MCP server URL for token audience (RFC 8707 Resource Indicator)
|
|
// Use public URL that clients/browsers see, not internal Docker URL
|
|
$mcpServerPublicUrl = $this->config->getSystemValue('mcp_server_public_url', $mcpServerUrl);
|
|
|
|
// Build authorization URL parameters
|
|
$params = [
|
|
'client_id' => $this->client->getClientId(),
|
|
'redirect_uri' => $redirectUri,
|
|
'response_type' => 'code',
|
|
'scope' => 'openid profile email offline_access', // Request MCP scopes
|
|
'state' => $state,
|
|
'resource' => $mcpServerPublicUrl, // RFC 8707 Resource Indicator - request token with MCP server audience
|
|
];
|
|
|
|
// Add PKCE parameters (always required per RFC 9207)
|
|
$params['code_challenge'] = $codeChallenge;
|
|
$params['code_challenge_method'] = 'S256';
|
|
|
|
return $authEndpoint . '?' . http_build_query($params);
|
|
}
|
|
|
|
/**
|
|
* Exchange authorization code for access token.
|
|
*
|
|
* Always uses PKCE code_verifier (RFC 9207).
|
|
* For confidential clients: Also includes client_secret for additional security.
|
|
* For public clients: Uses PKCE code_verifier only.
|
|
*
|
|
* 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
|
|
* @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,
|
|
): array {
|
|
// Query MCP server to discover which IdP it's configured to use
|
|
try {
|
|
$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');
|
|
}
|
|
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('Failed to fetch MCP server status during token exchange', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw new \Exception('Cannot connect to MCP server: ' . $e->getMessage());
|
|
}
|
|
|
|
// Determine OIDC discovery URL and token endpoint
|
|
$useInternalNextcloud = !isset($statusData['oidc']['discovery_url']);
|
|
|
|
if (!$useInternalNextcloud) {
|
|
// External IdP configured - use discovery
|
|
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
|
|
|
try {
|
|
$response = $this->httpClient->get($discoveryUrl);
|
|
$discovery = json_decode($response->getBody(), true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE || !isset($discovery['token_endpoint'])) {
|
|
throw new \RuntimeException('Invalid OIDC discovery response');
|
|
}
|
|
|
|
$tokenEndpoint = $discovery['token_endpoint'];
|
|
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('OIDC discovery failed during token exchange', [
|
|
'discovery_url' => $discoveryUrl,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw new \Exception('Failed to discover token endpoint: ' . $e->getMessage());
|
|
}
|
|
} else {
|
|
// Nextcloud's OIDC app - use internal URL directly (no HTTP request needed)
|
|
// This avoids network issues when overwritehost includes external port
|
|
$tokenEndpoint = 'http://localhost/apps/oidc/token';
|
|
}
|
|
|
|
$redirectUri = $this->urlGenerator->linkToRouteAbsolute(
|
|
'astrolabe.oauth.oauthCallback'
|
|
);
|
|
|
|
// Build token request parameters
|
|
$postData = [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $code,
|
|
'redirect_uri' => $redirectUri,
|
|
'client_id' => $this->client->getClientId(),
|
|
];
|
|
|
|
// Always include PKCE code verifier (RFC 9207)
|
|
$postData['code_verifier'] = $codeVerifier;
|
|
|
|
// Also include client secret if configured (defense in depth for confidential clients)
|
|
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
|
if (!empty($clientSecret)) {
|
|
$postData['client_secret'] = $clientSecret;
|
|
$this->logger->info('Using PKCE with client secret for token exchange');
|
|
} else {
|
|
$this->logger->info('Using PKCE only for token exchange');
|
|
}
|
|
|
|
// Use Nextcloud's HTTP client for token request
|
|
try {
|
|
$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 server');
|
|
}
|
|
|
|
return $tokenData;
|
|
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('Token exchange failed', [
|
|
'error' => $e->getMessage(),
|
|
'token_endpoint' => $tokenEndpoint,
|
|
]);
|
|
throw new \Exception('Token exchange failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Base64 URL-safe encoding (for PKCE).
|
|
*
|
|
* @param string $data Data to encode
|
|
* @return string Base64 URL-encoded string
|
|
*/
|
|
private function base64UrlEncode(string $data): string {
|
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
|
}
|
|
}
|