fix: address PR #589 review findings

- Fix anyio.Lock() created at module import time; use lazy init in
  get_shared_storage() to avoid instantiation before event loop exists
- Stop get_login_flow_session from silently swallowing DB exceptions;
  re-raise and handle in caller with proper error response
- Update ProvisionAccessResponse and UpdateScopesResponse status field
  docs to include all actual values (declined, cancelled, unchanged)
- Narrow except clause in present_login_url to (AttributeError,
  NotImplementedError) instead of bare Exception
- Add KeyError handling in LoginFlowV2Client.initiate() and poll() for
  clear errors on malformed Nextcloud responses
- Simplify redundant env-var bypass branches in scope_authorization.py
- Extract _maybe_login_flow_cleanup() context manager to replace 4
  inline cleanup loop registrations in app.py; move sleep to end of
  loop body so cleanup runs once at startup
- Replace fragile string replacement in _rewrite_login_flow_url with
  proper urllib.parse URL handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-02 09:10:57 +01:00
co-authored by Claude Opus 4.6
parent 1a6ce0fa7d
commit ba597634bd
8 changed files with 73 additions and 55 deletions
+5 -3
View File
@@ -70,7 +70,9 @@ async def present_login_url(
logger.info("User cancelled login flow")
return "cancelled"
except Exception as e:
# Elicitation not supported by this client - fall back to message
logger.debug(f"Elicitation not available ({e}), returning URL in message")
except (AttributeError, NotImplementedError) as e:
# Elicitation not supported by this client/SDK - fall back to message
logger.debug(
f"Elicitation not available ({type(e).__name__}: {e}), returning URL in message"
)
return "message_only"
+21 -11
View File
@@ -90,11 +90,16 @@ class LoginFlowV2Client:
poll_data = data.get("poll", {})
result = LoginFlowInitResponse(
login_url=data["login"],
poll_endpoint=poll_data["endpoint"],
poll_token=poll_data["token"],
)
try:
result = LoginFlowInitResponse(
login_url=data["login"],
poll_endpoint=poll_data["endpoint"],
poll_token=poll_data["token"],
)
except KeyError as e:
raise ValueError(
f"Malformed Login Flow v2 initiate response from Nextcloud (missing key: {e})"
) from e
logger.info(f"Login Flow v2 initiated: login_url={result.login_url[:60]}...")
return result
@@ -129,12 +134,17 @@ class LoginFlowV2Client:
f"Login Flow v2 completed: server={data.get('server')}, "
f"loginName={data.get('loginName')}"
)
return LoginFlowPollResult(
status="completed",
server=data["server"],
login_name=data["loginName"],
app_password=data["appPassword"],
)
try:
return LoginFlowPollResult(
status="completed",
server=data["server"],
login_name=data["loginName"],
app_password=data["appPassword"],
)
except KeyError as e:
raise ValueError(
f"Malformed Login Flow v2 poll response from Nextcloud (missing key: {e})"
) from e
if response.status_code == 404:
logger.debug("Login Flow v2 still pending")
@@ -128,20 +128,17 @@ def require_scopes(*required_scopes: str):
)
if access_token is None:
# Check if single-user BasicAuth mode (env var app password)
# If NEXTCLOUD_APP_PASSWORD or NEXTCLOUD_PASSWORD is set, bypass scope checks
# No OAuth token — either BasicAuth with env var credentials
# or BasicAuth without explicit credentials. Both bypass scope checks.
settings = get_settings()
if settings.nextcloud_app_password or settings.nextcloud_password:
logger.debug(
f"No access token for {func_name} - allowing (env var app password)"
)
return await func(*args, **kwargs)
# Not in OAuth mode (BasicAuth or no auth)
# In BasicAuth mode, all operations are allowed
logger.debug(
f"No access token present for {func_name} - allowing (BasicAuth mode)"
)
else:
logger.debug(
f"No access token present for {func_name} - allowing (BasicAuth mode)"
)
return await func(*args, **kwargs)
# ── Login Flow v2: Check stored app password scopes ──
+5 -3
View File
@@ -1779,7 +1779,7 @@ class RefreshTokenStorage:
logger.error(
f"Failed to retrieve login flow session for user {user_id}: {e}"
)
return None
raise
async def delete_login_flow_session(self, user_id: str) -> bool:
"""Delete a Login Flow v2 session.
@@ -1861,7 +1861,7 @@ class RefreshTokenStorage:
_shared_instance: RefreshTokenStorage | None = None
_shared_lock = anyio.Lock()
_shared_lock: anyio.Lock | None = None
async def get_shared_storage() -> RefreshTokenStorage:
@@ -1871,7 +1871,9 @@ async def get_shared_storage() -> RefreshTokenStorage:
creating their own lazy singletons. The lock ensures thread-safe
initialization on concurrent first-access.
"""
global _shared_instance
global _shared_instance, _shared_lock
if _shared_lock is None:
_shared_lock = anyio.Lock()
async with _shared_lock:
if _shared_instance is None:
_shared_instance = RefreshTokenStorage.from_env()