refactor: convert f-string logging to lazy %-style format (G004)

Sweep all 1676 G004 violations across 112 files, converting
`logger.<level>(f"…{x}…")` to `logger.<level>("…%s…", x)`.

Why: ruff rule G004 was added to pyproject.toml to enforce lazy
%-style logging — defers formatting until the log level is enabled
and lets structured log tooling match the unformatted template.

Conversion preserves rendered output byte-for-byte:
- `{x}` → `%s` + `x`
- `{x!r}` / `{x!s}` / `{x!a}` → `%r` / `%s` / `%a`
- Format specs (`{x:.2f}`, `{x:>10}`) → `%s` + `format(x, 'spec')`
  (printf-style specs aren't 1:1 with Python format specs, so we
  delegate to `format()` to keep identical output)
- Literal `%` → `%%`
- Concatenated f-strings (`f"a {x} " "b"`) flattened
- Trailing kwargs (`exc_info=True`) preserved

Verified:
- `uv run ruff check --select G004` → 0 violations
- `uv run ty check -- nextcloud_mcp_server` → passes
- `uv run pytest tests/unit/` → 1010 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-13 01:12:17 +02:00
co-authored by Claude Opus 4.7
parent a4e6125d28
commit 665cb9b1eb
112 changed files with 2534 additions and 1859 deletions
@@ -61,12 +61,12 @@ class AstrolabeClient:
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
async with nextcloud_httpx_client() as client:
logger.debug(f"Discovering token endpoint from {discovery_url}")
logger.debug("Discovering token endpoint from %s", discovery_url)
discovery_resp = await client.get(discovery_url)
discovery_resp.raise_for_status()
token_endpoint = discovery_resp.json()["token_endpoint"]
logger.debug(f"Requesting client credentials token from {token_endpoint}")
logger.debug("Requesting client credentials token from %s", token_endpoint)
# Request token using client credentials grant
token_resp = await client.post(
@@ -88,7 +88,7 @@ class AstrolabeClient:
"expires_at": time.time() + expires_in - 60,
}
logger.info(f"Obtained Astrolabe API token (expires in {expires_in}s)")
logger.info("Obtained Astrolabe API token (expires in %ss)", expires_in)
return data["access_token"]
async def get_user_app_password(self, user_id: str) -> Optional[str]:
@@ -108,7 +108,7 @@ class AstrolabeClient:
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
async with nextcloud_httpx_client() as client:
logger.debug(f"Retrieving app password for user: {user_id}")
logger.debug("Retrieving app password for user: %s", user_id)
response = await client.get(
url,
@@ -117,14 +117,16 @@ class AstrolabeClient:
)
if response.status_code == 404:
logger.debug(f"No app password configured for user: {user_id}")
logger.debug("No app password configured for user: %s", user_id)
return None
response.raise_for_status()
data = response.json()
logger.info(
f"Retrieved app password for user: {user_id} (type: {data.get('credential_type')})"
"Retrieved app password for user: %s (type: %s)",
user_id,
data.get("credential_type"),
)
return data.get("app_password")
@@ -133,8 +133,8 @@ async def register_client(
if resource_url:
client_metadata["resource_url"] = resource_url
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
logger.debug(f"Registration endpoint: {registration_endpoint}")
logger.info("Registering OAuth client with Nextcloud: %s", client_name)
logger.debug("Registration endpoint: %s", registration_endpoint)
async with nextcloud_httpx_client(timeout=30.0) as client:
for attempt in range(max_retries):
@@ -151,14 +151,17 @@ async def register_client(
retry_after = int(response.headers.get("Retry-After", 2))
wait_time = min(retry_after, 2**attempt)
logger.warning(
f"Rate limited (429) registering client, "
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
"Rate limited (429) registering client, retrying in %ss (attempt %s/%s)",
wait_time,
attempt + 1,
max_retries,
)
await anyio.sleep(wait_time)
continue
else:
logger.error(
f"Failed to register client after {max_retries} attempts: Rate limited (429)"
"Failed to register client after %s attempts: Rate limited (429)",
max_retries,
)
response.raise_for_status()
@@ -166,14 +169,15 @@ async def register_client(
client_info = response.json()
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
"Successfully registered client: %s", client_info.get("client_id")
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
"Client expires at: %s (in %s seconds)",
expires_at,
client_info.get("client_secret_expires_at", 0) - int(time.time()),
)
# Log if RFC 7592 fields are present
@@ -206,13 +210,13 @@ async def register_client(
except httpx.HTTPStatusError as e:
logger.error(
f"Failed to register client: HTTP {e.response.status_code}"
"Failed to register client: HTTP %s", e.response.status_code
)
logger.error(f"Response: {e.response.text}")
logger.error("Response: %s", e.response.text)
raise
except KeyError as e:
logger.error(
f"Invalid response from registration endpoint: missing {e}"
"Invalid response from registration endpoint: missing %s", e
)
raise ValueError(f"Invalid registration response: missing {e}")
@@ -264,8 +268,8 @@ async def delete_client(
else:
deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}"
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
logger.info("Deleting OAuth client: %s...", client_id[:16])
logger.debug("Deletion endpoint: %s", deletion_endpoint)
async with nextcloud_httpx_client(timeout=30.0) as http_client:
for attempt in range(max_retries):
@@ -296,7 +300,7 @@ async def delete_client(
# RFC 7592: Successful deletion returns 204 No Content
if response.status_code == 204:
logger.info(
f"Successfully deleted OAuth client: {client_id[:16]}..."
"Successfully deleted OAuth client: %s...", client_id[:16]
)
return True
elif response.status_code == 429:
@@ -307,42 +311,53 @@ async def delete_client(
retry_after, 2**attempt
) # Exponential backoff, max from header
logger.warning(
f"Rate limited (429) deleting client {client_id[:16]}..., "
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
"Rate limited (429) deleting client %s..., retrying in %ss (attempt %s/%s)",
client_id[:16],
wait_time,
attempt + 1,
max_retries,
)
await anyio.sleep(wait_time)
continue
else:
logger.error(
f"Failed to delete client {client_id[:16]}... after {max_retries} attempts: Rate limited (429)"
"Failed to delete client %s... after %s attempts: Rate limited (429)",
client_id[:16],
max_retries,
)
return False
elif response.status_code == 401:
logger.error(
f"Failed to delete client {client_id[:16]}...: Authentication failed (invalid credentials)"
"Failed to delete client %s...: Authentication failed (invalid credentials)",
client_id[:16],
)
return False
elif response.status_code == 403:
logger.error(
f"Failed to delete client {client_id[:16]}...: Not authorized (not a DCR client or wrong client)"
"Failed to delete client %s...: Not authorized (not a DCR client or wrong client)",
client_id[:16],
)
return False
else:
logger.error(
f"Failed to delete client {client_id[:16]}...: HTTP {response.status_code}"
"Failed to delete client %s...: HTTP %s",
client_id[:16],
response.status_code,
)
logger.debug(f"Response: {response.text}")
logger.debug("Response: %s", response.text)
return False
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error deleting client {client_id[:16]}...: {e.response.status_code}"
"HTTP error deleting client %s...: %s",
client_id[:16],
e.response.status_code,
)
logger.debug(f"Response: {e.response.text}")
logger.debug("Response: %s", e.response.text)
return False
except Exception as e:
logger.error(
f"Unexpected error deleting client {client_id[:16]}...: {e}"
"Unexpected error deleting client %s...: %s", client_id[:16], e
)
return False
@@ -390,14 +405,14 @@ async def ensure_oauth_client(
client_data = await storage.get_oauth_client()
if client_data:
logger.info(
f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..."
"Loaded OAuth client from SQLite: %s...", client_data["client_id"][:16]
)
return ClientInfo.from_dict(client_data)
# Register new client
logger.info("Registering new OAuth client...")
if resource_url:
logger.info(f" with resource_url: {resource_url}")
logger.info(" with resource_url: %s", resource_url)
client_info = await register_client(
nextcloud_url=nextcloud_url,
registration_endpoint=registration_endpoint,
+14 -12
View File
@@ -89,7 +89,7 @@ class ClientRegistry:
if not cid or not redirect:
logger.warning(
f"Skipping malformed ALLOWED_MCP_CLIENTS entry: {entry!r}"
"Skipping malformed ALLOWED_MCP_CLIENTS entry: %r", entry
)
continue
@@ -97,8 +97,9 @@ class ClientRegistry:
hostname = parsed.hostname
if hostname is None:
logger.warning(
f"Skipping client {cid!r}: cannot parse hostname "
f"from {redirect!r}"
"Skipping client %r: cannot parse hostname from %r",
cid,
redirect,
)
continue
is_loopback = hostname in ("localhost", "127.0.0.1", "::1")
@@ -108,8 +109,9 @@ class ClientRegistry:
or (parsed.scheme == "http" and is_loopback)
):
logger.warning(
f"Rejecting client {cid!r}: HTTP redirect URIs are only "
f"allowed for localhost, got {redirect!r}"
"Rejecting client %r: HTTP redirect URIs are only allowed for localhost, got %r",
cid,
redirect,
)
continue
@@ -120,7 +122,7 @@ class ClientRegistry:
allowed_scopes=["*"],
is_public=True,
)
logger.info(f"Registered static client: {cid}")
logger.info("Registered static client: %s", cid)
else:
self._clients[entry] = MCPClientInfo(
client_id=entry,
@@ -129,7 +131,7 @@ class ClientRegistry:
allowed_scopes=["*"],
is_public=True,
)
logger.info(f"Registered static client: {entry}")
logger.info("Registered static client: %s", entry)
if not self._clients:
logger.warning(
@@ -170,7 +172,7 @@ class ClientRegistry:
if not client:
if self.allow_dynamic_registration:
# In production, would attempt DCR here
logger.info(f"Unknown client {client_id}, would attempt DCR")
logger.info("Unknown client %s, would attempt DCR", client_id)
return True, None
else:
return False, f"Unknown client_id: {client_id}"
@@ -229,15 +231,15 @@ class ClientRegistry:
True if registered successfully
"""
if not self.allow_dynamic_registration:
logger.warning(f"DCR disabled, cannot register {client_info.client_id}")
logger.warning("DCR disabled, cannot register %s", client_info.client_id)
return False
if client_info.client_id in self._clients:
logger.warning(f"Client {client_info.client_id} already registered")
logger.warning("Client %s already registered", client_info.client_id)
return False
self._clients[client_info.client_id] = client_info
logger.info(f"Dynamically registered client: {client_info.client_id}")
logger.info("Dynamically registered client: %s", client_info.client_id)
# In production, would persist to database
return True
@@ -263,7 +265,7 @@ class ClientRegistry:
allowed_scopes=["*"], # Nextcloud enforces actual scopes
is_public=True,
)
logger.info(f"Registered proxy client: {client_id}")
logger.info("Registered proxy client: %s", client_id)
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
"""
+3 -3
View File
@@ -54,8 +54,8 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
raise ValueError("Username not available in OAuth token context")
logger.debug(
f"Creating NextcloudClient for user {username} with multi-audience token "
f"(no exchange needed)"
"Creating NextcloudClient for user %s with multi-audience token (no exchange needed)",
username,
)
# Token was validated to have MCP audience
@@ -65,6 +65,6 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
)
except AttributeError as e:
logger.error(f"Failed to extract OAuth context: {e}")
logger.error("Failed to extract OAuth context: %s", e)
logger.error("This may indicate the server is not running in OAuth mode")
raise
+6 -5
View File
@@ -129,7 +129,7 @@ class LoginFlowV2Client:
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]}...")
logger.info("Login Flow v2 initiated: login_url=%s...", result.login_url[:60])
return result
def _rewrite_to_nextcloud_host(self, url: str) -> str:
@@ -142,7 +142,7 @@ class LoginFlowV2Client:
"""
result = rewrite_url_origin(url, self.nextcloud_host)
if result != url:
logger.debug(f"Rewrote Login Flow v2 URL: {url}{result}")
logger.debug("Rewrote Login Flow v2 URL: %s%s", url, result)
return result
async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult:
@@ -172,8 +172,9 @@ class LoginFlowV2Client:
if response.status_code == 200:
data = response.json()
logger.info(
f"Login Flow v2 completed: server={data.get('server')}, "
f"loginName={data.get('loginName')}"
"Login Flow v2 completed: server=%s, loginName=%s",
data.get("server"),
data.get("loginName"),
)
try:
return LoginFlowPollResult(
@@ -193,6 +194,6 @@ class LoginFlowV2Client:
# Any other status indicates the flow has expired or is invalid
logger.warning(
f"Login Flow v2 poll returned unexpected status: {response.status_code}"
"Login Flow v2 poll returned unexpected status: %s", response.status_code
)
return LoginFlowPollResult(status="expired")
+11 -7
View File
@@ -359,7 +359,8 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
if auth_parsed.query:
authorization_endpoint += f"?{auth_parsed.query}"
logger.info(
f"Rewrote authorization endpoint for browser access: {authorization_endpoint}"
"Rewrote authorization endpoint for browser access: %s",
authorization_endpoint,
)
# Prefix resource scopes with the resource server identifier if configured.
@@ -952,7 +953,7 @@ async def _oauth_callback_as_proxy(
if response.status_code != 200:
logger.error(
f"AS proxy token exchange failed: {response.status_code} {response.text}"
"AS proxy token exchange failed: %s %s", response.status_code, response.text
)
params = urlencode(
{
@@ -968,8 +969,8 @@ async def _oauth_callback_as_proxy(
nc_token_response = response.json()
logger.info(
"AS proxy: Successfully exchanged code for Nextcloud token "
f"(token_type={nc_token_response.get('token_type')})"
"AS proxy: Successfully exchanged code for Nextcloud token (token_type=%s)",
nc_token_response.get("token_type"),
)
# Verify the ID token signature + claims before caching the response
@@ -1017,7 +1018,8 @@ async def _oauth_callback_as_proxy(
redirect_url = f"{session.client_redirect_uri}?{redirect_params}"
logger.info(
f"AS proxy: Redirecting to client with proxy_code (client_id={session.client_id})"
"AS proxy: Redirecting to client with proxy_code (client_id=%s)",
session.client_id,
)
return RedirectResponse(redirect_url, status_code=302)
@@ -1294,7 +1296,7 @@ async def _token_refresh(request: Request, form) -> JSONResponse:
if response.status_code != 200:
logger.error(
f"AS proxy token refresh failed: {response.status_code} {response.text}"
"AS proxy token refresh failed: %s %s", response.status_code, response.text
)
return JSONResponse(
{
@@ -1396,7 +1398,9 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
if response.status_code not in (200, 201):
logger.error(
f"DCR proxy: Upstream registration failed: {response.status_code} {response.text}"
"DCR proxy: Upstream registration failed: %s %s",
response.status_code,
response.text,
)
return JSONResponse(
response.json()
+5 -2
View File
@@ -44,11 +44,14 @@ async def is_nextcloud_admin(request: Request, http_client: AsyncClient) -> bool
# Check if user is in the admin group
is_admin = "admin" in user_groups
logger.debug(
f"Admin check for user '{username}': {is_admin} (groups: {user_groups})"
"Admin check for user '%s': %s (groups: %s)",
username,
is_admin,
user_groups,
)
return is_admin
except Exception as e:
logger.error(f"Error checking admin permissions: {e}", exc_info=True)
logger.error("Error checking admin permissions: %s", e, exc_info=True)
return False
+16 -13
View File
@@ -90,7 +90,7 @@ async def _poll_and_store(provision_id: str) -> None:
result = await flow_client.poll(poll_endpoint, poll_token)
except Exception as e:
logger.warning(
f"Login Flow v2 poll error for provision {provision_id}: {e}"
"Login Flow v2 poll error for provision %s: %s", provision_id, e
)
await anyio.sleep(2)
continue
@@ -106,7 +106,8 @@ async def _poll_and_store(provision_id: str) -> None:
if session:
session["status"] = "error"
logger.error(
f"Login Flow v2 completed but no app_password (provision_id={provision_id})"
"Login Flow v2 completed but no app_password (provision_id=%s)",
provision_id,
)
return
await storage.store_app_password_with_scopes(
@@ -121,8 +122,9 @@ async def _poll_and_store(provision_id: str) -> None:
session["status"] = "completed"
session["username"] = result.login_name
logger.info(
f"Login Flow v2 web provision completed for user {effective_user_id} "
f"(provision_id={provision_id})"
"Login Flow v2 web provision completed for user %s (provision_id=%s)",
effective_user_id,
provision_id,
)
return
@@ -131,7 +133,7 @@ async def _poll_and_store(provision_id: str) -> None:
if session:
session["status"] = "expired"
logger.warning(
f"Login Flow v2 web provision expired (provision_id={provision_id})"
"Login Flow v2 web provision expired (provision_id=%s)", provision_id
)
return
@@ -142,7 +144,7 @@ async def _poll_and_store(provision_id: str) -> None:
if session:
session["status"] = "expired"
logger.warning(
f"Login Flow v2 web provision timed out (provision_id={provision_id})"
"Login Flow v2 web provision timed out (provision_id=%s)", provision_id
)
@@ -166,7 +168,7 @@ async def provision_page(
try:
user_id, _token_data = await validate_token_and_get_user(request)
except (ValueError, KeyError, AttributeError) as e:
logger.warning(f"Provision request rejected: {e}")
logger.warning("Provision request rejected: %s", e)
return JSONResponse({"error": "Authentication required"}, status_code=401)
_cleanup_expired_sessions()
@@ -180,14 +182,14 @@ async def provision_page(
)
if urlparse(redirect_uri).scheme == "http":
logger.warning(f"Provision redirect_uri uses insecure HTTP: {redirect_uri}")
logger.warning("Provision redirect_uri uses insecure HTTP: %s", redirect_uri)
# Check if user already has an app password — skip straight to redirect
if user_id:
storage = await get_shared_storage()
existing = await storage.get_app_password_with_scopes(user_id)
if existing:
logger.info(f"User {user_id} already has app password, skipping provision")
logger.info("User %s already has app password, skipping provision", user_id)
return RedirectResponse(redirect_uri)
# Initiate Login Flow v2
@@ -206,7 +208,7 @@ async def provision_page(
)
init_response = await flow_client.initiate()
except Exception as e:
logger.error(f"Failed to initiate Login Flow v2 for web provision: {e}")
logger.error("Failed to initiate Login Flow v2 for web provision: %s", e)
return HTMLResponse(
content=_render_error(
"Failed to start login flow. Please try again later."
@@ -241,8 +243,9 @@ async def provision_page(
poll_tg.start_soon(_poll_and_store, provision_id)
logger.info(
f"Login Flow v2 web provision initiated (provision_id={provision_id}, "
f"user_id={user_id or 'unknown'}), redirecting to NC login"
"Login Flow v2 web provision initiated (provision_id=%s, user_id=%s), redirecting to NC login",
provision_id,
user_id or "unknown",
)
# Redirect to Nextcloud's Login Flow v2 login page.
@@ -277,7 +280,7 @@ async def provision_status(request: Request) -> JSONResponse:
try:
_user_id, _token_data = await validate_token_and_get_user(request)
except (ValueError, KeyError, AttributeError) as e:
logger.warning(f"Provision status request rejected: {e}")
logger.warning("Provision status request rejected: %s", e)
return JSONResponse({"error": "Authentication required"}, status_code=401)
provision_id = request.query_params.get("id", "")
@@ -89,7 +89,7 @@ def require_provisioning(func: Callable) -> Callable:
if not refresh_data:
# User has not completed Flow 2 - provide helpful error
logger.info(
f"User {user_id} attempted to use Nextcloud tool without provisioning"
"User %s attempted to use Nextcloud tool without provisioning", user_id
)
raise McpError(
ErrorData(
@@ -104,7 +104,7 @@ def require_provisioning(func: Callable) -> Callable:
)
logger.debug(
f"User {user_id} has provisioned access - proceeding with tool execution"
"User %s has provisioned access - proceeding with tool execution", user_id
)
# User has provisioned - allow access
@@ -156,15 +156,14 @@ def require_provisioning_or_suggest(func: Callable) -> Callable:
if not refresh_data:
logger.info(
f"User {user_id} has not provisioned Nextcloud access. "
"Some features may not work. Consider running "
"'provision_nextcloud_access' tool."
"User %s has not provisioned Nextcloud access. Some features may not work. Consider running 'provision_nextcloud_access' tool.",
user_id,
)
else:
logger.debug(f"User {user_id} has provisioned access")
logger.debug("User %s has provisioned access", user_id)
except Exception as e:
logger.debug(f"Could not check provisioning status: {e}")
logger.debug("Could not check provisioning status: %s", e)
# Always proceed with the function
return await func(*args, **kwargs)
@@ -119,7 +119,7 @@ def require_scopes(*required_scopes: str):
# No context parameter found - likely BasicAuth mode
# In BasicAuth mode, all operations are allowed
logger.debug(
f"No context parameter for {func_name} - allowing (BasicAuth mode)"
"No context parameter for %s - allowing (BasicAuth mode)", func_name
)
return await func(*args, **kwargs)
@@ -131,7 +131,7 @@ def require_scopes(*required_scopes: str):
if access_token is None:
# No OAuth token — BasicAuth mode bypasses scope checks
logger.debug(
f"No access token for {func_name} - allowing (BasicAuth mode)"
"No access token for %s - allowing (BasicAuth mode)", func_name
)
return await func(*args, **kwargs)
@@ -210,7 +210,8 @@ def require_scopes(*required_scopes: str):
if stored_scopes == "all":
# NULL scopes in DB = legacy app password = all allowed
logger.debug(
f"Stored app password scope check passed for {func_name}: all scopes"
"Stored app password scope check passed for %s: all scopes",
func_name,
)
return await func(*args, **kwargs)
@@ -227,7 +228,7 @@ def require_scopes(*required_scopes: str):
raise InsufficientScopeError(list(missing), error_msg)
logger.debug(
f"Stored app password scope check passed for {func_name}"
"Stored app password scope check passed for %s", func_name
)
return await func(*args, **kwargs)
@@ -303,7 +304,7 @@ def require_scopes(*required_scopes: str):
# All required scopes present - allow execution
logger.debug(
f"Scope authorization passed for {func_name}: {required_scopes}"
"Scope authorization passed for %s: %s", func_name, required_scopes
)
return await func(*args, **kwargs)
@@ -366,7 +367,7 @@ def get_access_token_scopes(ctx: Context | None = None) -> set[str]:
scopes = set(access_token.scopes or [])
scopes = _strip_resource_prefix(scopes)
logger.info(f"✅ Extracted scopes from access token: {scopes}")
logger.info("✅ Extracted scopes from access token: %s", scopes)
return scopes
@@ -446,7 +447,7 @@ def is_jwt_token() -> bool:
token_string = access_token.token
is_jwt = "." in token_string and token_string.count(".") == 2
logger.debug(f"Token format check: is_jwt={is_jwt}")
logger.debug("Token format check: is_jwt=%s", is_jwt)
return is_jwt
+68 -51
View File
@@ -184,12 +184,12 @@ class RefreshTokenStorage:
if has_schema:
logger.info(
f"Detected pre-Alembic database at {self.db_path}, "
"stamping with initial revision"
"Detected pre-Alembic database at %s, stamping with initial revision",
self.db_path,
)
else:
logger.info(
f"Initializing new database at {self.db_path} with migrations"
"Initializing new database at %s with migrations", self.db_path
)
# Run migrations in a worker thread using anyio.to_thread
@@ -215,7 +215,7 @@ class RefreshTokenStorage:
os.chmod(self.db_path, 0o600)
self._initialized = True
logger.info(f"Initialized refresh token storage at {self.db_path}")
logger.info("Initialized refresh token storage at %s", self.db_path)
async def store_refresh_token(
self,
@@ -333,7 +333,7 @@ class RefreshTokenStorage:
)
await db.commit()
logger.debug(f"Cached user profile for {user_id}")
logger.debug("Cached user profile for %s", user_id)
async def get_user_profile(self, user_id: str) -> dict[str, Any] | None:
"""
@@ -419,7 +419,7 @@ class RefreshTokenStorage:
row = await cursor.fetchone()
if not row:
logger.debug(f"No refresh token found for user {user_id}")
logger.debug("No refresh token found for user %s", user_id)
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
@@ -437,7 +437,9 @@ class RefreshTokenStorage:
# Check expiration
if expires_at is not None and expires_at < time.time():
logger.warning(
f"Refresh token for user {user_id} has expired (expired at {expires_at})"
"Refresh token for user %s has expired (expired at %s)",
user_id,
expires_at,
)
await self.delete_refresh_token(user_id)
duration = time.time() - start_time
@@ -448,7 +450,9 @@ class RefreshTokenStorage:
scopes = json.loads(scopes_json) if scopes_json else None
logger.debug(
f"Retrieved refresh token for user {user_id} (flow_type: {flow_type})"
"Retrieved refresh token for user %s (flow_type: %s)",
user_id,
flow_type,
)
duration = time.time() - start_time
@@ -467,7 +471,7 @@ class RefreshTokenStorage:
except Exception as e:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
logger.error(f"Failed to decrypt refresh token for user {user_id}: {e}")
logger.error("Failed to decrypt refresh token for user %s: %s", user_id, e)
return None
async def get_refresh_token_by_provisioning_client_id(
@@ -511,7 +515,8 @@ class RefreshTokenStorage:
if not row:
logger.debug(
f"No refresh token found for provisioning_client_id {provisioning_client_id[:16]}..."
"No refresh token found for provisioning_client_id %s...",
provisioning_client_id[:16],
)
return None
@@ -529,7 +534,8 @@ class RefreshTokenStorage:
# Check expiration
if expires_at is not None and expires_at < time.time():
logger.warning(
f"Refresh token for provisioning_client_id {provisioning_client_id[:16]}... has expired"
"Refresh token for provisioning_client_id %s... has expired",
provisioning_client_id[:16],
)
return None
@@ -538,7 +544,9 @@ class RefreshTokenStorage:
scopes = json.loads(scopes_json) if scopes_json else None
logger.debug(
f"Retrieved refresh token for provisioning_client_id {provisioning_client_id[:16]}... (user_id: {user_id})"
"Retrieved refresh token for provisioning_client_id %s... (user_id: %s)",
provisioning_client_id[:16],
user_id,
)
return {
@@ -553,7 +561,9 @@ class RefreshTokenStorage:
}
except Exception as e:
logger.error(
f"Failed to decrypt refresh token for provisioning_client_id {provisioning_client_id[:16]}...: {e}"
"Failed to decrypt refresh token for provisioning_client_id %s...: %s",
provisioning_client_id[:16],
e,
)
return None
@@ -584,14 +594,14 @@ class RefreshTokenStorage:
record_db_operation("sqlite", "delete", duration, "success")
if deleted:
logger.info(f"Deleted refresh token for user {user_id}")
logger.info("Deleted refresh token for user %s", user_id)
await self._audit_log(
event="delete_refresh_token",
user_id=user_id,
auth_method="offline_access",
)
else:
logger.debug(f"No refresh token to delete for user {user_id}")
logger.debug("No refresh token to delete for user %s", user_id)
return deleted
except Exception:
@@ -616,7 +626,7 @@ class RefreshTokenStorage:
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows]
logger.debug(f"Found {len(user_ids)} users with refresh tokens")
logger.debug("Found %s users with refresh tokens", len(user_ids))
return user_ids
async def cleanup_expired_tokens(self) -> int:
@@ -640,7 +650,7 @@ class RefreshTokenStorage:
deleted = cursor.rowcount
if deleted > 0:
logger.info(f"Cleaned up {deleted} expired refresh token(s)")
logger.info("Cleaned up %s expired refresh token(s)", deleted)
return deleted
@@ -718,8 +728,9 @@ class RefreshTokenStorage:
await db.commit()
logger.info(
f"Stored OAuth client credentials (client_id: {client_id[:16]}..., "
f"expires at {client_secret_expires_at})"
"Stored OAuth client credentials (client_id: %s..., expires at %s)",
client_id[:16],
client_secret_expires_at,
)
# Audit log
@@ -785,7 +796,7 @@ class RefreshTokenStorage:
# Check expiration
if expires_at < time.time():
logger.warning(
f"OAuth client has expired (expired at {expires_at}), deleting"
"OAuth client has expired (expired at %s), deleting", expires_at
)
await self.delete_oauth_client()
return None
@@ -803,7 +814,7 @@ class RefreshTokenStorage:
redirect_uris = json.loads(redirect_uris_json)
logger.debug(
f"Retrieved OAuth client credentials (client_id: {client_id[:16]}...)"
"Retrieved OAuth client credentials (client_id: %s...)", client_id[:16]
)
return {
@@ -817,7 +828,7 @@ class RefreshTokenStorage:
}
except Exception as e:
logger.error(f"Failed to decrypt OAuth client credentials: {e}")
logger.error("Failed to decrypt OAuth client credentials: %s", e)
return None
async def delete_oauth_client(self) -> bool:
@@ -1017,7 +1028,9 @@ class RefreshTokenStorage:
)
await db.commit()
logger.debug(f"Stored OAuth session {session_id} (expires in {ttl_seconds}s)")
logger.debug(
"Stored OAuth session %s (expires in %ss)", session_id, ttl_seconds
)
async def get_oauth_session(self, session_id: str) -> dict | None:
"""
@@ -1043,7 +1056,7 @@ class RefreshTokenStorage:
# Check expiration
if session["expires_at"] < time.time():
logger.debug(f"OAuth session {session_id} has expired")
logger.debug("OAuth session %s has expired", session_id)
await self.delete_oauth_session(session_id)
return None
@@ -1077,7 +1090,8 @@ class RefreshTokenStorage:
# Check expiration
if session["expires_at"] < time.time():
logger.debug(
f"OAuth session with MCP code {mcp_authorization_code[:16]}... has expired"
"OAuth session with MCP code %s... has expired",
mcp_authorization_code[:16],
)
await self.delete_oauth_session(session["session_id"])
return None
@@ -1133,7 +1147,7 @@ class RefreshTokenStorage:
updated = cursor.rowcount > 0
if updated:
logger.debug(f"Updated OAuth session {session_id}")
logger.debug("Updated OAuth session %s", session_id)
return updated
@@ -1155,7 +1169,7 @@ class RefreshTokenStorage:
deleted = cursor.rowcount > 0
if deleted:
logger.debug(f"Deleted OAuth session {session_id}")
logger.debug("Deleted OAuth session %s", session_id)
return deleted
@@ -1179,7 +1193,7 @@ class RefreshTokenStorage:
deleted = cursor.rowcount
if deleted > 0:
logger.info(f"Cleaned up {deleted} expired OAuth session(s)")
logger.info("Cleaned up %s expired OAuth session(s)", deleted)
return deleted
@@ -1339,7 +1353,7 @@ class RefreshTokenStorage:
)
await db.commit()
logger.debug(f"Stored webhook {webhook_id} for preset '{preset_id}'")
logger.debug("Stored webhook %s for preset '%s'", webhook_id, preset_id)
async def get_webhooks_by_preset(self, preset_id: str) -> list[int]:
"""
@@ -1384,7 +1398,7 @@ class RefreshTokenStorage:
deleted = cursor.rowcount > 0
if deleted:
logger.debug(f"Deleted webhook {webhook_id} from tracking")
logger.debug("Deleted webhook %s from tracking", webhook_id)
return deleted
@@ -1430,7 +1444,7 @@ class RefreshTokenStorage:
deleted = cursor.rowcount
if deleted > 0:
logger.debug(f"Cleared {deleted} webhook(s) for preset '{preset_id}'")
logger.debug("Cleared %s webhook(s) for preset '%s'", deleted, preset_id)
return deleted
@@ -1482,7 +1496,7 @@ class RefreshTokenStorage:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(f"Stored app password for user {user_id}")
logger.info("Stored app password for user %s", user_id)
except Exception:
duration = time.time() - start_time
@@ -1525,7 +1539,7 @@ class RefreshTokenStorage:
row = await cursor.fetchone()
if not row:
logger.debug(f"No app password found for user {user_id}")
logger.debug("No app password found for user %s", user_id)
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
@@ -1535,14 +1549,14 @@ class RefreshTokenStorage:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
logger.debug(f"Retrieved app password for user {user_id}")
logger.debug("Retrieved app password for user %s", user_id)
return decrypted_password
except Exception as e:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
logger.error(f"Failed to decrypt app password for user {user_id}: {e}")
logger.error("Failed to decrypt app password for user %s: %s", user_id, e)
return None
async def delete_app_password(self, user_id: str) -> bool:
@@ -1572,14 +1586,14 @@ class RefreshTokenStorage:
record_db_operation("sqlite", "delete", duration, "success")
if deleted:
logger.info(f"Deleted app password for user {user_id}")
logger.info("Deleted app password for user %s", user_id)
await self._audit_log(
event="delete_app_password",
user_id=user_id,
auth_method="app_password",
)
else:
logger.debug(f"No app password to delete for user {user_id}")
logger.debug("No app password to delete for user %s", user_id)
return deleted
@@ -1605,7 +1619,7 @@ class RefreshTokenStorage:
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows]
logger.debug(f"Found {len(user_ids)} users with app passwords")
logger.debug("Found %s users with app passwords", len(user_ids))
return user_ids
async def cleanup_invalid_app_passwords(self, nextcloud_host: str) -> list[str]:
@@ -1651,19 +1665,21 @@ class RefreshTokenStorage:
if response.status_code in (401, 403):
logger.info(
f"App password for {user_id} is invalid "
f"(HTTP {response.status_code}), removing"
"App password for %s is invalid (HTTP %s), removing",
user_id,
response.status_code,
)
await self.delete_app_password(user_id)
removed.append(user_id)
else:
logger.debug(
f"App password for {user_id} validated "
f"(HTTP {response.status_code})"
"App password for %s validated (HTTP %s)",
user_id,
response.status_code,
)
except Exception as e:
logger.warning(f"Could not validate app password for {user_id}: {e}")
logger.warning("Could not validate app password for %s: %s", user_id, e)
async with anyio.create_task_group() as tg:
for user_id in user_ids:
@@ -1745,9 +1761,10 @@ class RefreshTokenStorage:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(
f"Stored scoped app password for user {user_id} "
f"(scopes={'all' if scopes is None else len(scopes)}, "
f"username={username or 'N/A'})"
"Stored scoped app password for user %s (scopes=%s, username=%s)",
user_id,
"all" if scopes is None else len(scopes),
username or "N/A",
)
except Exception:
@@ -1793,7 +1810,7 @@ class RefreshTokenStorage:
row = await cursor.fetchone()
if not row:
logger.debug(f"No app password found for user {user_id}")
logger.debug("No app password found for user %s", user_id)
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
@@ -1917,7 +1934,7 @@ class RefreshTokenStorage:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(f"Stored login flow session for user {user_id}")
logger.info("Stored login flow session for user %s", user_id)
except Exception:
duration = time.time() - start_time
@@ -1984,7 +2001,7 @@ class RefreshTokenStorage:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
logger.error(
f"Failed to retrieve login flow session for user {user_id}: {e}"
"Failed to retrieve login flow session for user %s: %s", user_id, e
)
raise
@@ -2014,7 +2031,7 @@ class RefreshTokenStorage:
record_db_operation("sqlite", "delete", duration, "success")
if deleted:
logger.info(f"Deleted login flow session for user {user_id}")
logger.info("Deleted login flow session for user %s", user_id)
await self._audit_log(
event="delete_login_flow_session",
user_id=user_id,
@@ -2052,7 +2069,7 @@ class RefreshTokenStorage:
record_db_operation("sqlite", "delete", duration, "success")
if count > 0:
logger.info(f"Cleaned up {count} expired login flow sessions")
logger.info("Cleaned up %s expired login flow sessions", count)
await self._audit_log(
event="delete_expired_login_flow_sessions",
user_id="system",
+39 -29
View File
@@ -56,15 +56,15 @@ class TokenCache:
# Check if token has expired
if now >= expiry:
del self._cache[user_id]
logger.debug(f"Cached token expired for user {user_id}")
logger.debug("Cached token expired for user %s", user_id)
return None
# Check if token will expire soon (refresh early)
if now >= expiry - self._early_refresh:
logger.debug(f"Cached token expiring soon for user {user_id}")
logger.debug("Cached token expiring soon for user %s", user_id)
return None
logger.debug(f"Using cached token for user {user_id}")
logger.debug("Using cached token for user %s", user_id)
return token
async def set(self, user_id: str, token: str, expires_in: int | None = None):
@@ -77,14 +77,14 @@ class TokenCache:
expiry = datetime.now(timezone.utc) + self._ttl
self._cache[user_id] = (token, expiry)
logger.debug(f"Cached token for user {user_id} until {expiry}")
logger.debug("Cached token for user %s until %s", user_id, expiry)
async def invalidate(self, user_id: str):
"""Remove token from cache."""
async with self._lock:
if user_id in self._cache:
del self._cache[user_id]
logger.debug(f"Invalidated cached token for user {user_id}")
logger.debug("Invalidated cached token for user %s", user_id)
class TokenBrokerService:
@@ -214,7 +214,7 @@ class TokenBrokerService:
# Get stored refresh token
refresh_data = await self.storage.get_refresh_token(user_id)
if not refresh_data:
logger.info(f"No refresh token found for user {user_id}")
logger.info("No refresh token found for user %s", user_id)
return None
try:
@@ -230,7 +230,7 @@ class TokenBrokerService:
return access_token
except Exception as e:
logger.error(f"Failed to get Nextcloud token for user {user_id}: {e}")
logger.error("Failed to get Nextcloud token for user %s: %s", user_id, e)
# Invalidate cache on error
await self.cache.invalidate(user_id)
return None
@@ -273,19 +273,21 @@ class TokenBrokerService:
cached_token = await self.cache.get(cache_key)
if cached_token:
logger.debug(
f"Token found in cache after lock acquisition for user {user_id}"
"Token found in cache after lock acquisition for user %s", user_id
)
return cached_token
# Check if another thread is currently refreshing
if await self.cache.get(refresh_in_progress_key):
logger.debug(f"Refresh in progress for user {user_id}, waiting briefly")
logger.debug(
"Refresh in progress for user %s, waiting briefly", user_id
)
await anyio.sleep(0.1) # Brief wait for in-progress refresh
# Check cache one more time after wait
cached_token = await self.cache.get(cache_key)
if cached_token:
logger.debug(
f"Token refreshed by another thread for user {user_id}"
"Token refreshed by another thread for user %s", user_id
)
return cached_token
@@ -296,7 +298,7 @@ class TokenBrokerService:
# Get stored refresh token
refresh_data = await self.storage.get_refresh_token(user_id)
if not refresh_data:
logger.info(f"No refresh token found for user {user_id}")
logger.info("No refresh token found for user %s", user_id)
return None
# storage.get_refresh_token() returns already-decrypted token
@@ -312,14 +314,18 @@ class TokenBrokerService:
await self.cache.set(cache_key, access_token, expires_in)
logger.info(
f"Generated background token for user {user_id} with scopes: {required_scopes}"
"Generated background token for user %s with scopes: %s",
user_id,
required_scopes,
)
return access_token
except Exception as e:
logger.error(
f"Failed to get background token for user {user_id}: {e}",
"Failed to get background token for user %s: %s",
user_id,
e,
exc_info=True,
)
await self.cache.invalidate(cache_key)
@@ -375,7 +381,7 @@ class TokenBrokerService:
if response.status_code != 200:
logger.error(
f"Token refresh failed: {response.status_code} - {response.text}"
"Token refresh failed: %s - %s", response.status_code, response.text
)
raise Exception(f"Token refresh failed: {response.status_code}")
@@ -395,11 +401,11 @@ class TokenBrokerService:
refresh_token=new_refresh_token,
expires_at=expires_at,
)
logger.info(f"Stored rotated refresh token for user {user_id}")
logger.info("Stored rotated refresh token for user %s", user_id)
# Note: Nextcloud validates token audience on API calls - no need to pre-validate here
logger.info(f"Refreshed access token (expires in {expires_in}s)")
logger.info("Refreshed access token (expires in %ss)", expires_in)
return access_token, expires_in
async def _refresh_access_token_with_scopes(
@@ -446,7 +452,9 @@ class TokenBrokerService:
}
logger.info(
f"Token refresh request to {token_endpoint} with client_id={self.client_id[:16]}..."
"Token refresh request to %s with client_id=%s...",
token_endpoint,
self.client_id[:16],
)
response = await client.post(
@@ -457,9 +465,11 @@ class TokenBrokerService:
if response.status_code != 200:
logger.error(
f"Token refresh with scopes failed: {response.status_code} - {response.text}"
"Token refresh with scopes failed: %s - %s",
response.status_code,
response.text,
)
logger.error(f" client_id used: {self.client_id[:16]}...")
logger.error(" client_id used: %s...", self.client_id[:16])
raise Exception(f"Token refresh failed: {response.status_code}")
token_data = response.json()
@@ -479,12 +489,12 @@ class TokenBrokerService:
refresh_token=new_refresh_token,
expires_at=expires_at,
)
logger.info(f"Stored rotated refresh token for user {user_id}")
logger.info("Stored rotated refresh token for user %s", user_id)
# Note: Nextcloud validates token audience on API calls - no need to pre-validate here
logger.info(
f"Refreshed access token with scopes {scopes} (expires in {expires_in}s)"
"Refreshed access token with scopes %s (expires in %ss)", scopes, expires_in
)
return access_token, expires_in
@@ -503,7 +513,7 @@ class TokenBrokerService:
"""
refresh_data = await self.storage.get_refresh_token(user_id)
if not refresh_data:
logger.warning(f"No refresh token to rotate for user {user_id}")
logger.warning("No refresh token to rotate for user %s", user_id)
return False
try:
@@ -538,7 +548,7 @@ class TokenBrokerService:
)
if response.status_code != 200:
logger.error(f"Master token refresh failed: {response.status_code}")
logger.error("Master token refresh failed: %s", response.status_code)
return False
token_data = response.json()
@@ -555,7 +565,7 @@ class TokenBrokerService:
refresh_token=new_refresh_token,
expires_at=expires_at,
)
logger.info(f"Rotated master refresh token for user {user_id}")
logger.info("Rotated master refresh token for user %s", user_id)
# Invalidate cached access token
await self.cache.invalidate(user_id)
@@ -564,7 +574,7 @@ class TokenBrokerService:
return True
except Exception as e:
logger.error(f"Failed to refresh master token for user {user_id}: {e}")
logger.error("Failed to refresh master token for user %s: %s", user_id, e)
return False
async def has_nextcloud_provisioning(self, user_id: str) -> bool:
@@ -601,7 +611,7 @@ class TokenBrokerService:
refresh_token = refresh_data["refresh_token"]
await self._revoke_token_at_idp(refresh_token)
except Exception as e:
logger.warning(f"Failed to revoke at IdP: {e}")
logger.warning("Failed to revoke at IdP: %s", e)
# Remove from storage
await self.storage.delete_refresh_token(user_id)
@@ -609,11 +619,11 @@ class TokenBrokerService:
# Clear cache
await self.cache.invalidate(user_id)
logger.info(f"Revoked Nextcloud access for user {user_id}")
logger.info("Revoked Nextcloud access for user %s", user_id)
return True
except Exception as e:
logger.error(f"Failed to revoke access for user {user_id}: {e}")
logger.error("Failed to revoke access for user %s: %s", user_id, e)
return False
async def _revoke_token_at_idp(self, token: str):
@@ -638,7 +648,7 @@ class TokenBrokerService:
if response.status_code == 200:
logger.info("Token revoked at IdP")
else:
logger.warning(f"Token revocation returned {response.status_code}")
logger.warning("Token revocation returned %s", response.status_code)
async def close(self):
"""Clean up resources."""
+34 -25
View File
@@ -69,7 +69,7 @@ class UnifiedTokenVerifier(TokenVerifier):
# JWT verification support
self.jwks_client: PyJWKClient | None = None
if hasattr(settings, "jwks_uri") and settings.jwks_uri:
logger.info(f"JWT verification enabled with JWKS URI: {settings.jwks_uri}")
logger.info("JWT verification enabled with JWKS URI: %s", settings.jwks_uri)
self.jwks_client = PyJWKClient(settings.jwks_uri, cache_keys=True)
# Introspection support (for opaque tokens)
@@ -81,7 +81,7 @@ class UnifiedTokenVerifier(TokenVerifier):
and settings.oidc_client_secret
):
self.introspection_uri = settings.introspection_uri
logger.info(f"Token introspection enabled: {self.introspection_uri}")
logger.info("Token introspection enabled: %s", 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
@@ -114,14 +114,16 @@ class UnifiedTokenVerifier(TokenVerifier):
)
else:
logger.info(
f"Management API allowlist: {sorted(self._allowed_mgmt_clients)}"
"Management API allowlist: %s", sorted(self._allowed_mgmt_clients)
)
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"Valid issuers: {self.valid_issuers}"
"UnifiedTokenVerifier initialized in %s mode. MCP audience: %s or %s, Nextcloud resource URI: %s, Valid issuers: %s",
self.mode,
settings.oidc_client_id,
settings.nextcloud_mcp_server_url,
settings.nextcloud_resource_uri,
self.valid_issuers,
)
async def verify_token(self, token: str) -> AccessToken | None:
@@ -281,9 +283,10 @@ class UnifiedTokenVerifier(TokenVerifier):
if not self._has_mcp_audience(payload):
audiences = payload.get("aud", [])
logger.error(
f"Token rejected: Missing MCP audience. "
f"Got {audiences}, need MCP ({self.settings.oidc_client_id} or "
f"{self.settings.nextcloud_mcp_server_url})"
"Token rejected: Missing MCP audience. Got %s, need MCP (%s or %s)",
audiences,
self.settings.oidc_client_id,
self.settings.nextcloud_mcp_server_url,
)
# Record as invalid due to audience mismatch
record_oauth_token_validation(validation_method, "invalid")
@@ -303,7 +306,7 @@ class UnifiedTokenVerifier(TokenVerifier):
return self._create_access_token(token, payload)
except Exception as e:
logger.error(f"Token verification failed: {e}")
logger.error("Token verification failed: %s", e)
record_oauth_token_validation(validation_method, "error")
return None
@@ -368,14 +371,15 @@ class UnifiedTokenVerifier(TokenVerifier):
# Skip audience validation - any valid Nextcloud token is accepted
logger.debug(
f"Management API token validated (no audience check) for user: {payload.get('sub')}"
"Management API token validated (no audience check) for user: %s",
payload.get("sub"),
)
# Cache and return the token
return self._create_access_token_with_cache_key(token, payload, cache_key)
except Exception as e:
logger.error(f"Management API token verification failed: {e}")
logger.error("Management API token verification failed: %s", e)
record_oauth_token_validation(validation_method, "error")
return None
@@ -478,20 +482,20 @@ class UnifiedTokenVerifier(TokenVerifier):
f"expected one of: {self.valid_issuers}"
)
logger.debug(f"JWT signature verified for user: {payload.get('sub')}")
logger.debug("JWT signature verified for user: %s", payload.get("sub"))
return payload
except jwt.ExpiredSignatureError:
logger.info("JWT token has expired")
return None
except jwt.InvalidIssuerError as e:
logger.warning(f"JWT issuer validation failed: {e}")
logger.warning("JWT issuer validation failed: %s", e)
return None
except jwt.InvalidTokenError as e:
logger.warning(f"JWT validation failed: {e}")
logger.warning("JWT validation failed: %s", e)
return None
except Exception as e:
logger.error(f"Unexpected error during JWT verification: {e}")
logger.error("Unexpected error during JWT verification: %s", e)
return None
async def _introspect_token(self, token: str) -> dict[str, Any] | None:
@@ -528,20 +532,23 @@ class UnifiedTokenVerifier(TokenVerifier):
return None
logger.debug(
f"Token introspected successfully for user: {introspection_data.get('sub')}"
"Token introspected successfully for user: %s",
introspection_data.get("sub"),
)
return introspection_data
elif response.status_code in (400, 401, 403):
logger.warning(
f"Token introspection failed: HTTP {response.status_code}. "
f"Response: {response.text[:200] if response.text else 'empty'}"
"Token introspection failed: HTTP %s. Response: %s",
response.status_code,
response.text[:200] if response.text else "empty",
)
return None
else:
logger.warning(
f"Unexpected response from introspection: {response.status_code}. "
f"Response: {response.text[:200] if response.text else 'empty'}"
"Unexpected response from introspection: %s. Response: %s",
response.status_code,
response.text[:200] if response.text else "empty",
)
return None
@@ -549,10 +556,10 @@ class UnifiedTokenVerifier(TokenVerifier):
logger.error("Timeout while introspecting token")
return None
except httpx.RequestError as e:
logger.error(f"Network error while introspecting token: {e}")
logger.error("Network error while introspecting token: %s", e)
return None
except Exception as e:
logger.error(f"Unexpected error during token introspection: {e}")
logger.error("Unexpected error during token introspection: %s", e)
return None
def _create_access_token(
@@ -598,7 +605,9 @@ class UnifiedTokenVerifier(TokenVerifier):
scope_string = payload.get("scope", "")
scopes = scope_string.split() if scope_string else []
logger.debug(
f"Extracted scopes from token - scope claim: '{scope_string}' -> scopes list: {scopes}"
"Extracted scopes from token - scope claim: '%s' -> scopes list: %s",
scope_string,
scopes,
)
# Extract expiration
+16 -15
View File
@@ -143,7 +143,7 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
indexed_count = count_result.count
except Exception as e:
logger.warning(f"Failed to query Qdrant for indexed count: {e}")
logger.warning("Failed to query Qdrant for indexed count: %s", e)
# Continue with indexed_count = 0
# Determine status
@@ -156,7 +156,7 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
}
except Exception as e:
logger.error(f"Error getting processing status: {e}")
logger.error("Error getting processing status: %s", e)
return None
@@ -243,11 +243,11 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
try:
await oauth_client.discover()
except Exception as e:
logger.error(f"Failed to discover IdP endpoints: {e}")
logger.error("Failed to discover IdP endpoints: %s", e)
return None
logger.debug(
f"Using external IdP userinfo endpoint: {oauth_client.userinfo_endpoint}"
"Using external IdP userinfo endpoint: %s", oauth_client.userinfo_endpoint
)
return oauth_client.userinfo_endpoint
@@ -269,7 +269,8 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
if userinfo_endpoint:
logger.debug(
f"Using Nextcloud userinfo endpoint from discovery: {userinfo_endpoint}"
"Using Nextcloud userinfo endpoint from discovery: %s",
userinfo_endpoint,
)
return userinfo_endpoint
@@ -277,7 +278,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
return None
except Exception as e:
logger.error(f"Failed to query discovery document for userinfo endpoint: {e}")
logger.error("Failed to query discovery document for userinfo endpoint: %s", e)
return None
@@ -302,7 +303,7 @@ async def _query_idp_userinfo(
response.raise_for_status()
return response.json()
except Exception as e:
logger.warning(f"Failed to query IdP userinfo endpoint: {e}")
logger.warning("Failed to query IdP userinfo endpoint: %s", e)
return None
@@ -379,9 +380,9 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
# Include cached profile if available
if profile_data:
user_context["idp_profile"] = profile_data
logger.debug(f"Loaded cached profile for {session_id[:16]}...")
logger.debug("Loaded cached profile for %s...", session_id[:16])
else:
logger.warning(f"No cached profile found for {session_id[:16]}...")
logger.warning("No cached profile found for %s...", session_id[:16])
user_context["idp_profile_error"] = (
"Profile not cached. Try logging out and back in."
)
@@ -389,8 +390,8 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
return user_context
except Exception as e:
logger.error(f"Error retrieving user info: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
logger.error("Error retrieving user info: %s", e)
logger.error("Traceback: %s", traceback.format_exc())
return {
"error": f"Failed to retrieve user info: {e}",
"username": username,
@@ -439,7 +440,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
is_admin = await is_nextcloud_admin(request, nc_client._client)
await nc_client.close()
except Exception as e:
logger.warning(f"Failed to check admin status: {e}")
logger.warning("Failed to check admin status: %s", e)
# Default to not admin if check fails
# Check for error
@@ -697,9 +698,9 @@ async def revoke_session(request: Request) -> HTMLResponse:
try:
# Delete the refresh token
logger.info(f"Revoking background access for session {session_id[:16]}...")
logger.info("Revoking background access for session %s...", session_id[:16])
await storage.delete_refresh_token(session_id)
logger.info(f"✓ Background access revoked for session {session_id[:16]}...")
logger.info("✓ Background access revoked for session %s...", session_id[:16])
# Redirect back to user page
user_page_url = str(request.url_for("user_info_html"))
@@ -718,7 +719,7 @@ async def revoke_session(request: Request) -> HTMLResponse:
)
except Exception as e:
logger.error(f"Failed to revoke background access: {e}")
logger.error("Failed to revoke background access: %s", e)
template = _jinja_env.get_template("error.html")
return HTMLResponse(
content=template.render(
+56 -32
View File
@@ -140,8 +140,13 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
doc_types = doc_types_param.split(",") if doc_types_param else None
logger.info(
f"Viz search: user={username}, query='{query}', "
f"algorithm={algorithm}, fusion={fusion}, limit={limit}, doc_types={doc_types}"
"Viz search: user=%s, query='%s', algorithm=%s, fusion=%s, limit=%s, doc_types=%s",
username,
query,
algorithm,
fusion,
limit,
doc_types,
)
try:
@@ -230,8 +235,9 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
score_range = max_score - min_score if max_score > min_score else 1.0
logger.info(
f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] "
f"→ [0.0, 1.0]"
"Normalizing scores for viz: original range [%s, %s] → [0.0, 1.0]",
format(min_score, ".3f"),
format(max_score, ".3f"),
)
# Store original score and rescale to 0-1 for visualization
@@ -338,7 +344,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
status_code=500,
)
logger.info(f"Detected embedding dimension: {embedding_dim}")
logger.info("Detected embedding dimension: %s", embedding_dim)
# Build chunk vectors array in search_results order (1:1 mapping)
chunk_vectors = []
@@ -349,7 +355,8 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
else:
# Chunk not found in vectors (shouldn't happen)
logger.warning(
f"Chunk {chunk_key} not found in fetched vectors, using zero vector"
"Chunk %s not found in fetched vectors, using zero vector",
chunk_key,
)
# Use zero vector as fallback
chunk_vectors.append(np.zeros(embedding_dim))
@@ -361,14 +368,16 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
if search_algo.query_embedding is not None:
query_embedding = search_algo.query_embedding
logger.info(
f"Reusing query embedding from search algorithm "
f"(dimension={len(query_embedding)})"
"Reusing query embedding from search algorithm (dimension=%s)",
len(query_embedding),
)
else:
# Fallback: generate embedding if not available from search
embedding_service = get_embedding_service()
query_embedding = await embedding_service.embed(query)
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
logger.info(
"Generated query embedding (dimension=%s)", len(query_embedding)
)
query_embed_duration = time.perf_counter() - query_embed_start
# Combine query vector with chunk vectors for PCA
@@ -387,16 +396,19 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
if zero_norm_mask.any():
zero_indices = np.where(zero_norm_mask)[0]
logger.warning(
f"Found {zero_norm_mask.sum()} zero-norm vectors at indices {zero_indices.tolist()}. "
"Replacing with small epsilon to avoid division by zero."
"Found %s zero-norm vectors at indices %s. Replacing with small epsilon to avoid division by zero.",
zero_norm_mask.sum(),
zero_indices.tolist(),
)
# Replace zero norms with small epsilon to avoid NaN
norms[zero_norm_mask] = 1e-10
all_vectors_normalized = all_vectors / norms
logger.info(
f"Normalized vectors: query_norm={norms[-1][0]:.3f}, "
f"doc_norm_range=[{norms[:-1].min():.3f}, {norms[:-1].max():.3f}]"
"Normalized vectors: query_norm=%s, doc_norm_range=[%s, %s]",
format(norms[-1][0], ".3f"),
format(norms[:-1].min(), ".3f"),
format(norms[:-1].max(), ".3f"),
)
# Apply PCA dimensionality reduction (768-dim → 3D) on normalized vectors
@@ -428,8 +440,9 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
if nan_mask.any():
nan_rows = np.where(nan_mask.any(axis=1))[0]
logger.error(
f"Found NaN values in PCA output at {len(nan_rows)} points: {nan_rows.tolist()[:10]}. "
"Replacing NaN with 0.0 to prevent JSON serialization error."
"Found NaN values in PCA output at %s points: %s. Replacing NaN with 0.0 to prevent JSON serialization error.",
len(nan_rows),
nan_rows.tolist()[:10],
)
# Replace NaN with 0 to allow JSON serialization
coords_3d = np.nan_to_num(coords_3d, nan=0.0)
@@ -442,13 +455,16 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
chunk_coords_3d = coords_3d[:-1] # All but last are chunks
logger.info(
f"PCA explained variance: PC1={pca.explained_variance_ratio_[0]:.3f}, "
f"PC2={pca.explained_variance_ratio_[1]:.3f}, "
f"PC3={pca.explained_variance_ratio_[2]:.3f}"
"PCA explained variance: PC1=%s, PC2=%s, PC3=%s",
format(pca.explained_variance_ratio_[0], ".3f"),
format(pca.explained_variance_ratio_[1], ".3f"),
format(pca.explained_variance_ratio_[2], ".3f"),
)
logger.info(
f"Embedding stats: chunks={len(chunk_vectors)}, "
f"query_dim={len(query_embedding)}, chunk_vector_dim={chunk_vectors.shape[1] if chunk_vectors.size > 0 else 0}"
"Embedding stats: chunks=%s, query_dim=%s, chunk_vector_dim=%s",
len(chunk_vectors),
len(query_embedding),
chunk_vectors.shape[1] if chunk_vectors.size > 0 else 0,
)
# Coordinates already match search_results order (1:1 mapping)
@@ -479,12 +495,18 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
# Log comprehensive timing metrics
logger.info(
f"Viz search timing: total={total_duration * 1000:.1f}ms, "
f"search={search_duration * 1000:.1f}ms ({search_duration / total_duration * 100:.1f}%), "
f"vector_fetch={vector_fetch_duration * 1000:.1f}ms ({vector_fetch_duration / total_duration * 100:.1f}%), "
f"query_embed={query_embed_duration * 1000:.1f}ms ({query_embed_duration / total_duration * 100:.1f}%), "
f"pca={pca_duration * 1000:.1f}ms ({pca_duration / total_duration * 100:.1f}%), "
f"results={len(search_results)}, chunk_vectors={len(chunk_vectors)}"
"Viz search timing: total=%sms, search=%sms (%s%%), vector_fetch=%sms (%s%%), query_embed=%sms (%s%%), pca=%sms (%s%%), results=%s, chunk_vectors=%s",
format(total_duration * 1000, ".1f"),
format(search_duration * 1000, ".1f"),
format(search_duration / total_duration * 100, ".1f"),
format(vector_fetch_duration * 1000, ".1f"),
format(vector_fetch_duration / total_duration * 100, ".1f"),
format(query_embed_duration * 1000, ".1f"),
format(query_embed_duration / total_duration * 100, ".1f"),
format(pca_duration * 1000, ".1f"),
format(pca_duration / total_duration * 100, ".1f"),
len(search_results),
len(chunk_vectors),
)
return JSONResponse(
@@ -511,7 +533,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
)
except Exception as e:
logger.error(f"Viz search error: {e}", exc_info=True)
logger.error("Viz search error: %s", e, exc_info=True)
return JSONResponse(
{"success": False, "error": str(e)},
status_code=500,
@@ -637,10 +659,12 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
)
logger.info(
f"Fetched chunk context for {doc_type}_{doc_id}: "
f"chunk_len={len(chunk_context.chunk_text)}, "
f"before_len={len(chunk_context.before_context)}, "
f"after_len={len(chunk_context.after_context)}"
"Fetched chunk context for %s_%s: chunk_len=%s, before_len=%s, after_len=%s",
doc_type,
doc_id,
len(chunk_context.chunk_text),
len(chunk_context.before_context),
len(chunk_context.after_context),
)
# For PDF files, also fetch the chunk bbox from Qdrant so the client
@@ -688,7 +712,7 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
status_code=400,
)
except Exception as e:
logger.error(f"Chunk context error: {e}", exc_info=True)
logger.error("Chunk context error: %s", e, exc_info=True)
return JSONResponse(
{"success": False, "error": str(e)},
status_code=500,