fix: resolve OAuth compatibility issues for login-flow deployment
- Drop OIDC fork: comment out third_party/oidc mount, use upstream v1.16.3 from app store (fixes consent redirect race, PR #631) - Support client_secret_basic auth: add _extract_basic_auth() helper so TS MCP SDK can authenticate at token endpoint (RFC 6749 §2.3.1) - Multi-issuer JWT validation: accept tokens with internal Docker issuer (http://app:80) or public URL (NEXTCLOUD_PUBLIC_ISSUER_URL) since AS proxy obtains tokens server-to-server - Introspection fallback: try token introspection when JWT verification fails, supporting both JWT and opaque token types - Register all tool scopes in DCR: add semantic:read, collectives:read, collectives:write to OIDC client allowed_scopes so tokens include them and semantic search tools are visible to authenticated clients - Auto-create Astrolabe OAuth client: new app-hook creates OIDC client and stores credentials in config.php so the "Authorize via OAuth" button works without manual setup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3cf4c777ed
commit
fe8799a133
@@ -456,12 +456,24 @@ async def load_oauth_client_credentials(
|
||||
# and the authorization server will limit them to these allowed scopes.
|
||||
#
|
||||
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
|
||||
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write news:read news:write"
|
||||
# These must stay in sync — any scope a tool uses via @require_scopes must be listed here.
|
||||
dcr_scopes = (
|
||||
"openid profile email "
|
||||
"notes:read notes:write calendar:read calendar:write todo:read todo:write "
|
||||
"contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write "
|
||||
"tables:read tables:write files:read files:write sharing:read sharing:write "
|
||||
"news:read news:write collectives:read collectives:write"
|
||||
)
|
||||
|
||||
# Add offline_access scope if refresh tokens are enabled
|
||||
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
||||
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
||||
# Add conditional scopes based on server configuration
|
||||
dcr_settings = get_settings()
|
||||
|
||||
# semantic:read gates MCP-server-level semantic search tools
|
||||
if dcr_settings.vector_sync_enabled:
|
||||
dcr_scopes = f"{dcr_scopes} semantic:read"
|
||||
logger.info("✓ semantic:read scope enabled for semantic search tools")
|
||||
|
||||
# offline_access enables refresh tokens for background operations
|
||||
enable_offline_access = dcr_settings.enable_offline_access
|
||||
if enable_offline_access:
|
||||
dcr_scopes = f"{dcr_scopes} offline_access"
|
||||
|
||||
@@ -24,10 +24,10 @@ import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from base64 import b64decode, urlsafe_b64encode
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import unquote, urlencode
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
import jwt
|
||||
@@ -905,6 +905,19 @@ async def _oauth_callback_as_proxy(
|
||||
return RedirectResponse(redirect_url, status_code=302)
|
||||
|
||||
|
||||
def _extract_basic_auth(request: Request) -> tuple[str | None, str | None]:
|
||||
"""Extract client_id and client_secret from HTTP Basic Auth header (RFC 6749 §2.3.1)."""
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not auth_header.startswith("Basic "):
|
||||
return None, None
|
||||
try:
|
||||
decoded = b64decode(auth_header[6:]).decode("utf-8")
|
||||
client_id, _, client_secret = decoded.partition(":")
|
||||
return unquote(client_id), unquote(client_secret) if client_secret else None
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _verify_pkce_s256(code_verifier: str, code_challenge: str) -> bool:
|
||||
"""Verify PKCE S256 code_verifier against stored code_challenge.
|
||||
|
||||
@@ -958,6 +971,10 @@ async def _token_authorization_code(request: Request, form) -> JSONResponse:
|
||||
code_verifier = form.get("code_verifier")
|
||||
client_id = form.get("client_id")
|
||||
|
||||
# RFC 6749 §2.3.1: clients may authenticate via HTTP Basic Auth
|
||||
if not client_id:
|
||||
client_id, _ = _extract_basic_auth(request)
|
||||
|
||||
logger.debug(
|
||||
"AS proxy token: received code=%s client_id=%s redirect_uri=%s "
|
||||
"code_verifier=%s",
|
||||
|
||||
@@ -82,6 +82,17 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
self.introspection_uri = settings.introspection_uri
|
||||
logger.info(f"Token introspection enabled: {self.introspection_uri}")
|
||||
|
||||
# Build list of valid issuers (internal + public may differ in Docker)
|
||||
# AS proxy obtains tokens via internal URL (e.g. http://app:80), while
|
||||
# NEXTCLOUD_PUBLIC_ISSUER_URL is the browser-facing URL (e.g. http://localhost:8080)
|
||||
self.valid_issuers: list[str] = []
|
||||
if hasattr(settings, "oidc_issuer") and settings.oidc_issuer:
|
||||
self.valid_issuers.append(settings.oidc_issuer)
|
||||
if hasattr(settings, "nextcloud_host") and settings.nextcloud_host:
|
||||
host = settings.nextcloud_host.rstrip("/")
|
||||
if host not in self.valid_issuers:
|
||||
self.valid_issuers.append(host)
|
||||
|
||||
# Token cache: token_hash -> (userinfo, expiry_timestamp)
|
||||
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
self.cache_ttl = 3600 # 1 hour default
|
||||
@@ -89,7 +100,8 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
logger.info(
|
||||
f"UnifiedTokenVerifier initialized in {self.mode} mode. "
|
||||
f"MCP audience: {settings.oidc_client_id} or {settings.nextcloud_mcp_server_url}, "
|
||||
f"Nextcloud resource URI: {settings.nextcloud_resource_uri}"
|
||||
f"Nextcloud resource URI: {settings.nextcloud_resource_uri}, "
|
||||
f"Valid issuers: {self.valid_issuers}"
|
||||
)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
@@ -208,6 +220,14 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
record_oauth_token_validation("jwt", "valid")
|
||||
else:
|
||||
record_oauth_token_validation("jwt", "invalid")
|
||||
# Fall back to introspection if JWT verification failed
|
||||
if self.introspection_uri:
|
||||
validation_method = "introspect"
|
||||
payload = await self._introspect_token(token)
|
||||
if payload:
|
||||
record_oauth_token_validation("introspect", "valid")
|
||||
else:
|
||||
record_oauth_token_validation("introspect", "invalid")
|
||||
else:
|
||||
# Fall back to introspection for opaque tokens
|
||||
validation_method = "introspect"
|
||||
@@ -390,26 +410,30 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
|
||||
# Verify and decode JWT
|
||||
# Note: We don't validate audience here - that's done separately based on mode
|
||||
# Issuer validation can be skipped for management API tokens (from Astrolabe)
|
||||
should_verify_issuer = (
|
||||
not skip_issuer_check
|
||||
and hasattr(self.settings, "oidc_issuer")
|
||||
and self.settings.oidc_issuer
|
||||
)
|
||||
# Issuer is checked manually below to support multiple valid issuers
|
||||
# (internal Docker URL vs public URL in AS proxy deployments)
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=(self.settings.oidc_issuer if should_verify_issuer else None),
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_iat": True,
|
||||
"verify_iss": should_verify_issuer,
|
||||
"verify_aud": False, # We handle audience validation separately
|
||||
"verify_iss": False, # Checked manually below
|
||||
"verify_aud": False, # Handled separately based on mode
|
||||
},
|
||||
)
|
||||
|
||||
# Manual issuer validation against multiple valid issuers
|
||||
if not skip_issuer_check and self.valid_issuers:
|
||||
token_issuer = payload.get("iss")
|
||||
if token_issuer not in self.valid_issuers:
|
||||
raise jwt.InvalidIssuerError(
|
||||
f"Invalid issuer '{token_issuer}', "
|
||||
f"expected one of: {self.valid_issuers}"
|
||||
)
|
||||
|
||||
logger.debug(f"JWT signature verified for user: {payload.get('sub')}")
|
||||
return payload
|
||||
|
||||
|
||||
Reference in New Issue
Block a user