fix(oauth): enable PKCE for all clients and add token_broker to oauth_context

This commit fixes two OAuth issues in the Astrolabe app:

1. **Always use PKCE (RFC 9207)**:
   - PKCE is now used for all OAuth flows (public and confidential clients)
   - Previous code only used PKCE for public clients, causing failures
   - Confidential clients now use both PKCE + client_secret (defense in depth)
   - Nextcloud OIDC provider requires PKCE, so token exchange was failing

2. **Add token_broker to oauth_context**:
   - Token broker is now stored in oauth_context for management API access
   - Fixes "Token broker not configured" error when revoking access
   - Revoke endpoint needs token_broker to delete refresh tokens and invalidate cache

Changes:
- OAuthController.php: Always generate PKCE verifier/challenge for all clients
- OAuthController.php: Always include code_verifier in token exchange
- app.py: Store token_broker in oauth_context after creation

Fixes:
- Astrolabe OAuth flow now works with Nextcloud OIDC
- Revoke/disconnect functionality now works in Astrolabe settings

🤖 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-18 01:55:04 +01:00
co-authored by Claude Sonnet 4.5
parent 8d6eff2792
commit fe54733a39
2 changed files with 43 additions and 48 deletions
+5
View File
@@ -1445,6 +1445,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
client_secret=sync_client_secret, client_secret=sync_client_secret,
) )
# Store token broker in oauth_context for management API (revoke endpoint)
if hasattr(app.state, "oauth_context"):
app.state.oauth_context["token_broker"] = token_broker
logger.info("Token broker added to oauth_context for management API")
# Initialize Qdrant collection before starting background tasks # Initialize Qdrant collection before starting background tasks
logger.info("Initializing Qdrant collection...") logger.info("Initializing Qdrant collection...")
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
+38 -48
View File
@@ -24,9 +24,12 @@ use Psr\Log\LoggerInterface;
/** /**
* OAuth controller for MCP Server UI. * OAuth controller for MCP Server UI.
* *
* Implements OAuth 2.0 Authorization Code flow with support for both: * Implements OAuth 2.0 Authorization Code flow with PKCE (RFC 9207).
* - Confidential clients (with client_secret): Direct token refresh, no PKCE * PKCE is always used for all clients (public and confidential) as recommended
* - Public clients (without client_secret): PKCE-based flow for fallback * by modern OAuth 2.0 best practices.
*
* - Public clients: PKCE only
* - Confidential clients: PKCE + client_secret (defense in depth)
*/ */
class OAuthController extends Controller { class OAuthController extends Controller {
private $config; private $config;
@@ -67,10 +70,8 @@ class OAuthController extends Controller {
/** /**
* Initiate OAuth authorization flow. * Initiate OAuth authorization flow.
* *
* For confidential clients (with client_secret): Standard OAuth flow, no PKCE. * Always generates PKCE code verifier and challenge (RFC 9207).
* For public clients (without client_secret): Generates PKCE code verifier and challenge. * Stores state and code verifier in session, then redirects user to IdP authorization endpoint.
*
* Stores state in session, then redirects user to IdP authorization endpoint.
* *
* @return RedirectResponse|TemplateResponse * @return RedirectResponse|TemplateResponse
*/ */
@@ -98,31 +99,25 @@ class OAuthController extends Controller {
throw new \Exception('MCP server URL not configured'); throw new \Exception('MCP server URL not configured');
} }
// Check if confidential client secret is 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', ''); $clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
$isConfidentialClient = !empty($clientSecret); $isConfidentialClient = !empty($clientSecret);
// Generate PKCE values only for public clients if ($isConfidentialClient) {
$codeVerifier = null; $this->logger->info('Using confidential client mode with PKCE and client secret');
$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 { } else {
$this->logger->info('Using confidential client mode with client secret'); $this->logger->info('Using public client mode with PKCE only');
} }
// Generate state for CSRF protection // Generate state for CSRF protection
$state = bin2hex(random_bytes(16)); $state = bin2hex(random_bytes(16));
// Store values in session // Store values in session
if ($codeVerifier) { $this->session->set('mcp_oauth_code_verifier', $codeVerifier);
$this->session->set('mcp_oauth_code_verifier', $codeVerifier);
}
$this->session->set('mcp_oauth_state', $state); $this->session->set('mcp_oauth_state', $state);
$this->session->set('mcp_oauth_user_id', $user->getUID()); $this->session->set('mcp_oauth_user_id', $user->getUID());
@@ -181,13 +176,10 @@ class OAuthController extends Controller {
throw new \Exception('Invalid state parameter (CSRF protection)'); throw new \Exception('Invalid state parameter (CSRF protection)');
} }
// Get stored PKCE verifier (may be null for confidential clients) // Get stored PKCE verifier (always required)
$codeVerifier = $this->session->get('mcp_oauth_code_verifier'); $codeVerifier = $this->session->get('mcp_oauth_code_verifier');
if (empty($codeVerifier)) {
// Check if we have either client_secret or code_verifier throw new \Exception('PKCE code verifier not found in session');
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
if (empty($clientSecret) && empty($codeVerifier)) {
throw new \Exception('Neither client secret nor code verifier available for authentication');
} }
// Get user ID from session // Get user ID from session
@@ -287,16 +279,18 @@ class OAuthController extends Controller {
* to find the authorization endpoint. Supports both Nextcloud OIDC and * to find the authorization endpoint. Supports both Nextcloud OIDC and
* external IdPs like Keycloak. * external IdPs like Keycloak.
* *
* Always uses PKCE (RFC 9207 recommends PKCE for all clients).
*
* @param string $mcpServerUrl Base URL of MCP server * @param string $mcpServerUrl Base URL of MCP server
* @param string $state CSRF state parameter * @param string $state CSRF state parameter
* @param string|null $codeChallenge PKCE code challenge (null for confidential clients) * @param string $codeChallenge PKCE code challenge
* @return string Authorization URL * @return string Authorization URL
* @throws \Exception if OIDC discovery fails * @throws \Exception if OIDC discovery fails
*/ */
private function buildAuthorizationUrl( private function buildAuthorizationUrl(
string $mcpServerUrl, string $mcpServerUrl,
string $state, string $state,
?string $codeChallenge, string $codeChallenge,
): string { ): string {
// First, query MCP server to discover which IdP it's configured to use // First, query MCP server to discover which IdP it's configured to use
$this->logger->info('buildAuthorizationUrl: Starting', [ $this->logger->info('buildAuthorizationUrl: Starting', [
@@ -407,11 +401,9 @@ class OAuthController extends Controller {
'resource' => $mcpServerPublicUrl, // RFC 8707 Resource Indicator - request token with MCP server audience 'resource' => $mcpServerPublicUrl, // RFC 8707 Resource Indicator - request token with MCP server audience
]; ];
// Add PKCE parameters only for public clients // Add PKCE parameters (always required per RFC 9207)
if ($codeChallenge !== null) { $params['code_challenge'] = $codeChallenge;
$params['code_challenge'] = $codeChallenge; $params['code_challenge_method'] = 'S256';
$params['code_challenge_method'] = 'S256';
}
return $authEndpoint . '?' . http_build_query($params); return $authEndpoint . '?' . http_build_query($params);
} }
@@ -419,22 +411,23 @@ class OAuthController extends Controller {
/** /**
* Exchange authorization code for access token. * Exchange authorization code for access token.
* *
* For confidential clients: Uses client_secret for authentication. * Always uses PKCE code_verifier (RFC 9207).
* For public clients: Uses PKCE code_verifier for authentication. * 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 * Queries MCP server for IdP configuration, then performs OIDC discovery
* to find the token endpoint. Supports both Nextcloud OIDC and external IdPs. * to find the token endpoint. Supports both Nextcloud OIDC and external IdPs.
* *
* @param string $mcpServerUrl Base URL of MCP server * @param string $mcpServerUrl Base URL of MCP server
* @param string $code Authorization code * @param string $code Authorization code
* @param string|null $codeVerifier PKCE code verifier (null for confidential clients) * @param string $codeVerifier PKCE code verifier
* @return array Token data containing access_token, refresh_token, expires_in * @return array Token data containing access_token, refresh_token, expires_in
* @throws \Exception on HTTP or token error * @throws \Exception on HTTP or token error
*/ */
private function exchangeCodeForToken( private function exchangeCodeForToken(
string $mcpServerUrl, string $mcpServerUrl,
string $code, string $code,
?string $codeVerifier, string $codeVerifier,
): array { ): array {
// Query MCP server to discover which IdP it's configured to use // Query MCP server to discover which IdP it's configured to use
try { try {
@@ -494,19 +487,16 @@ class OAuthController extends Controller {
'client_id' => $this->client->getClientId(), 'client_id' => $this->client->getClientId(),
]; ];
// Add client authentication based on client type // Always include PKCE code verifier (RFC 9207)
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', ''); $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)) { if (!empty($clientSecret)) {
// Confidential client: use client secret for authentication
$postData['client_secret'] = $clientSecret; $postData['client_secret'] = $clientSecret;
$this->logger->info('Using client secret for token exchange'); $this->logger->info('Using PKCE with 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 { } else {
throw new \Exception('Neither client_secret nor code_verifier available for token exchange'); $this->logger->info('Using PKCE only for token exchange');
} }
// Use Nextcloud's HTTP client for token request // Use Nextcloud's HTTP client for token request