Files
mcp-nextcloud/nextcloud_mcp_server/auth/storage.py
T
Chris CoutinhoandClaude Opus 4.7 f2b7bf132f fix(storage): address PR #798 review feedback (credentials, asyncpg extra, TLS, pool)
Round-2 fixes after the bot review on PR #798 plus two user follow-ups
(self-signed Postgres support; asyncpg should be a PyPI extra). Folded
into the same PR rather than a follow-up since the work is still
unmerged.

Security
--------
- Mask database credentials in all 5 log call sites (storage.py × 4,
  migrations.py × 1) via a new `mask_db_password()` helper in config.py.
  Uses SQLAlchemy's `make_url(...).render_as_string(hide_password=True)`
  with a regex fallback so the masking path never raises.
- New `tests/unit/test_storage_logging.py` asserts a sentinel password
  never appears in `caplog` during `RefreshTokenStorage.initialize()`.

Distribution
------------
- `asyncpg` moved to `[project.optional-dependencies] postgres` so a
  vanilla `pip install nextcloud-mcp-server` no longer pulls in the
  ~5 MB C extension. The Docker image runs `uv sync --extra postgres`,
  so containerized deployments are unchanged.
- When `DATABASE_URL=postgresql+asyncpg://...` is set on a venv missing
  the extra, `RefreshTokenStorage.initialize()` raises a friendly
  RuntimeError pointing at `[postgres]` rather than the generic
  ModuleNotFoundError.

TLS for the Postgres backend
----------------------------
- New `DATABASE_VERIFY_SSL` + `DATABASE_CA_BUNDLE` env vars mirror the
  existing `NEXTCLOUD_VERIFY_SSL` / `NEXTCLOUD_CA_BUNDLE` pattern
  (validators in Settings.__post_init__, `get_database_ssl()` helper
  alongside `get_nextcloud_ssl_verify()`). `DATABASE_VERIFY_SSL=false`
  wins over `DATABASE_CA_BUNDLE` for incident-response convenience.
- Default is **None** rather than True — keeps PR #798's behavior
  intact for cluster-internal Postgres that runs without TLS. Operators
  opt into verify-full or supply a private CA. ADR-026 records the
  reasoning vs the Nextcloud HTTPS default.
- Engine factory in `storage.py` passes `ssl` via `connect_args` only
  when `get_database_ssl()` returns non-None; otherwise asyncpg's
  default (`prefer`) applies.
- Storage logs which TLS mode is active at INFO (no secret material).

Configurable connection pool
----------------------------
- `DATABASE_POOL_SIZE` (default 10) and `DATABASE_MAX_OVERFLOW`
  (default 20) replace the hardcoded engine values. With many replicas
  this can blow past managed-Postgres `max_connections=100`; tune down
  for large fleets.
- gte-1 / gte-0 validators in __post_init__ reject 0/negative pool
  sizes at startup with the offending value in the error.

Consistency polish
------------------
- Migration 006: convert raw `op.execute("ALTER TABLE ... ADD COLUMN")`
  to `op.batch_alter_table(...).add_column(sa.Column("nonce", sa.Text))`
  for stylistic consistency with the rewritten 001-005. Downgrade now
  drops the column instead of being a no-op.
- `registered_webhooks.created_at` standardized from `sa.Float` to
  `sa.BigInteger` (all other `*_at` columns); `store_webhook()` casts
  `time.time()` → `int`.
- `is_sqlite_url()` made case-insensitive.

Testing
-------
- New `tests/integration/test_storage_postgres.py::test_cleanup_expired_roundtrip`
  exercises `cleanup_expired_tokens`, `cleanup_expired_sessions`, and
  `cleanup_expired_browser_sessions` — relies on DELETE rowcount,
  historically dialect-tricky.
- `tests/unit/test_ssl_config.py` extended with `TestDatabaseSSLSettings`
  + `TestGetDatabaseSSL` classes (9 new tests) mirroring the existing
  Nextcloud SSL tests one-for-one.

Docs
----
- `docs/configuration.md` Centralized-Storage section grew the four new
  env vars + a homelab example with a private CA.
- `docs/ADR-026` grew Distribution, TLS, and `alembic/env.py` async-pattern
  subsections explaining the non-obvious design choices.

Helm chart counterpart in cbcoutinho/helm-charts PR #34 (separate
commit on `feat/nextcloud-mcp-server-database-url`).

Verification
------------
- `uv run pytest tests/unit/` — 1025 passed.
- `TEST_DATABASE_URL=... uv run pytest tests/integration/test_storage_postgres.py -m postgres` — 6 passed (including new cleanup test).
- `uv run ruff check && uv run ruff format --check && uv run ty check -- nextcloud_mcp_server` — clean.

Tracked on Astrolabe Cloud POC board, card #99.

---

_This PR was generated with the help of AI, and reviewed by a Human_

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:53:45 +02:00

2458 lines
85 KiB
Python

"""
Persistent Storage for MCP Server State
This module provides SQLite-based storage for multiple concerns across both
BasicAuth and OAuth authentication modes:
1. **Refresh Tokens** (OAuth mode only, for background jobs)
- Securely stores encrypted refresh tokens for offline access
- Used ONLY by background jobs to obtain access tokens
- NEVER used within MCP client sessions or browser sessions
2. **User Profile Cache** (OAuth mode only, for browser UI display)
- Caches IdP user profile data for browser-based admin UI
- Queried ONCE at login, displayed from cache thereafter
- NOT used for authorization decisions or background jobs
3. **Webhook Registration Tracking** (both modes, for webhook management)
- Tracks registered webhook IDs mapped to presets
- Enables persistent webhook state across restarts
- Avoids redundant Nextcloud API calls for webhook status
IMPORTANT: The database is initialized in both BasicAuth and OAuth modes.
Token storage requires TOKEN_ENCRYPTION_KEY, but webhook tracking does not.
Sensitive data (tokens, secrets) is encrypted at rest using Fernet symmetric encryption.
"""
import importlib.util
import json
import logging
import os
import socket
import sqlite3
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import anyio
import httpx
import sqlalchemy as sa
from anyio import to_thread
from cryptography.fernet import Fernet
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy.pool import NullPool
from nextcloud_mcp_server.config import (
get_database_ssl,
get_database_url,
get_settings,
is_ephemeral_token_db,
is_sqlite_url,
mask_db_password,
)
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
from nextcloud_mcp_server.observability.metrics import record_db_operation
logger = logging.getLogger(__name__)
def _qmark_to_named(sql: str) -> tuple[str, list[str]]:
"""Rewrite ``?`` positional placeholders to ``:p0, :p1, ...`` named binds.
SQLAlchemy's :func:`text` only supports named bind parameters, so the
aiosqlite-style call sites (which use ``?``) are translated as they
cross the shim. The rewriter preserves ``?`` characters inside SQL
string literals; comments are not currently respected but the storage
layer doesn't put ``?`` inside comments.
"""
out: list[str] = []
names: list[str] = []
i = 0
in_str = False
quote = ""
n = 0
while i < len(sql):
ch = sql[i]
if in_str:
out.append(ch)
if ch == quote:
# SQL string escapes ('' or "") — stay in string mode.
if i + 1 < len(sql) and sql[i + 1] == quote:
out.append(quote)
i += 2
continue
in_str = False
i += 1
continue
if ch in ("'", '"'):
in_str = True
quote = ch
out.append(ch)
i += 1
continue
if ch == "?":
name = f"p{n}"
out.append(f":{name}")
names.append(name)
n += 1
i += 1
continue
out.append(ch)
i += 1
return "".join(out), names
class _Row:
"""Hybrid tuple/dict row mirroring ``aiosqlite.Row`` semantics.
The legacy SQLite-direct call sites use a mix of access patterns:
positional unpacking (``a, b, c = row``), indexed access (``row[0]``),
and dict-like access (``row["col"]``, ``dict(row)``) when
``db.row_factory = aiosqlite.Row`` is set. To avoid touching every call
site, every row returned by :class:`_Cursor` is wrapped in this hybrid
object so all three patterns keep working.
"""
__slots__ = ("_values", "_mapping")
def __init__(self, values: tuple, mapping: dict) -> None:
self._values = values
self._mapping = mapping
def __getitem__(self, key):
if isinstance(key, (int, slice)):
return self._values[key]
return self._mapping[key]
def __iter__(self):
return iter(self._values)
def __len__(self) -> int:
return len(self._values)
def keys(self):
return self._mapping.keys()
def values(self):
return self._mapping.values()
def items(self):
return self._mapping.items()
def _wrap_row(row) -> _Row | None:
if row is None:
return None
return _Row(tuple(row), dict(row._mapping))
def _wrap_rows(rows) -> list[_Row]:
"""Wrap a list of SQLAlchemy rows; iterator never yields ``None``."""
return [_Row(tuple(r), dict(r._mapping)) for r in rows]
class _Cursor:
"""aiosqlite-compatible cursor view over a SQLAlchemy CursorResult.
Existing storage methods iterate cursors via ``async with db.execute(...)
as cursor: row = await cursor.fetchone()``. SQLAlchemy returns a
synchronous :class:`Result` from an async ``execute``; this shim adds the
async context-manager / async-fetch surface so call sites are unchanged.
``rowcount`` is captured eagerly at construction time. ``lastrowid`` is
intentionally NOT exposed: accessing ``CursorResult.lastrowid`` on the
asyncpg dialect consumes the result buffer, which would silently turn
every subsequent ``fetchall()`` into an empty list (a real bug hit
during the Postgres port).
"""
__slots__ = ("_result", "rowcount")
def __init__(self, result: sa.CursorResult) -> None:
self._result = result
# ``rowcount`` is -1 for SELECTs in SQLAlchemy; existing code only
# reads it after writes (DELETE/UPDATE) where it is accurate.
self.rowcount = result.rowcount
async def fetchone(self) -> _Row | None:
return _wrap_row(self._result.fetchone())
async def fetchall(self) -> list[_Row]:
return _wrap_rows(self._result.fetchall())
async def __aenter__(self) -> "_Cursor":
return self
async def __aexit__(self, *exc: object) -> None:
# SQLAlchemy Result closes when the connection closes; no-op here.
return None
class _ExecuteCtx:
"""Hybrid awaitable + async context manager for ``db.execute(...)``.
Aiosqlite call sites use both forms interchangeably::
cursor = await db.execute(sql, params)
async with db.execute(sql, params) as cursor: ...
so the return value must be awaitable (resolves to a cursor) AND a
one-shot async context manager (executes on ``__aenter__`` and returns
the cursor). This wrapper provides both surfaces without executing the
SQL twice — the cursor is cached after the first resolution.
"""
__slots__ = ("_conn", "_sql", "_params", "_cursor")
def __init__(self, conn: AsyncConnection, sql: str, params: tuple | list) -> None:
self._conn = conn
self._sql = sql
self._params = params
self._cursor: _Cursor | None = None
async def _resolve(self) -> _Cursor:
if self._cursor is not None:
return self._cursor
text_sql, names = _qmark_to_named(self._sql)
if len(names) != len(self._params):
raise ValueError(
f"Placeholder count mismatch: SQL has {len(names)} '?' "
f"but got {len(self._params)} params"
)
bind = dict(zip(names, self._params, strict=True))
result = await self._conn.execute(sa.text(text_sql), bind)
self._cursor = _Cursor(result)
return self._cursor
def __await__(self):
return self._resolve().__await__()
async def __aenter__(self) -> _Cursor:
return await self._resolve()
async def __aexit__(self, *exc: object) -> None:
return None
class _DBConn:
"""aiosqlite-compatible wrapper around a SQLAlchemy AsyncConnection.
Provides ``execute`` (with ``?`` placeholders, returning a hybrid
awaitable/context-manager :class:`_ExecuteCtx`) and ``commit`` so the
existing storage method bodies need no churn beyond swapping the
connection context-manager. Wraps results in :class:`_Cursor` for the
fetchone/fetchall/rowcount surface the call sites already use.
``row_factory`` is accepted as a setter for source compatibility with
aiosqlite call sites but is ignored: every row is already wrapped in
:class:`_Row` so dict-style access works unconditionally.
"""
def __init__(self, conn: AsyncConnection) -> None:
self._conn = conn
self.row_factory = None # set by aiosqlite-shaped call sites; ignored
def execute(self, sql: str, params: tuple | list = ()) -> _ExecuteCtx:
return _ExecuteCtx(self._conn, sql, params)
async def commit(self) -> None:
await self._conn.commit()
class RefreshTokenStorage:
"""Persistent storage for MCP server state (tokens, webhooks, and future features).
This class manages multiple concerns across both BasicAuth and OAuth modes:
**OAuth-specific concerns**:
- Refresh tokens: Encrypted storage for background job access (requires encryption key)
- User profiles: Plain JSON cache for browser UI display
- OAuth client credentials: Encrypted client secrets from DCR
- OAuth sessions: Temporary session state for progressive consent flow
**Both modes**:
- Webhook registration: Track registered webhooks mapped to presets
- Schema versioning: Handle database migrations automatically
Token-related operations require TOKEN_ENCRYPTION_KEY, but webhook operations do not.
"""
def __init__(
self,
database_url: str | None = None,
encryption_key: bytes | None = None,
*,
db_path: str | None = None,
):
"""
Initialize persistent storage.
Args:
database_url: SQLAlchemy URL (``sqlite+aiosqlite:///...`` or
``postgresql+asyncpg://...``). When omitted, falls back to
:func:`get_database_url` (honors ``DATABASE_URL`` env, then
``TOKEN_STORAGE_DB``).
encryption_key: Optional Fernet encryption key (32 bytes, base64-encoded).
Required for token storage operations, not required for webhook tracking.
db_path: Deprecated SQLite-only constructor argument retained for
tests that pass a tempfile path. Internally converted to
``sqlite+aiosqlite:///{db_path}``.
"""
if database_url is None and db_path is not None:
database_url = f"sqlite+aiosqlite:///{db_path}"
if database_url is None:
database_url = get_database_url()
self.database_url = database_url
# Legacy attribute retained for sqlite-only code paths (file perms,
# ephemeral tempfile detection, log messages). Empty string for
# non-sqlite URLs so accidental file ops fail loudly.
self.db_path = (
database_url.split("///", 1)[1] if is_sqlite_url(database_url) else ""
)
self.cipher = Fernet(encryption_key) if encryption_key else None
self.engine: AsyncEngine | None = None
self._dialect: str = "unknown"
self._initialized = False
@classmethod
def from_env(cls) -> "RefreshTokenStorage":
"""
Create storage instance from environment variables.
Environment variables:
DATABASE_URL: SQLAlchemy URL for any supported backend. Wins
over ``TOKEN_STORAGE_DB`` when set. Use
``postgresql+asyncpg://user:pw@host/db`` for HA k8s
deployments. See ADR-026.
TOKEN_STORAGE_DB: Legacy SQLite-only path. If unset and
``DATABASE_URL`` is also unset, a per-process tempfile is
allocated and deleted at interpreter exit — tokens are
ephemeral and wiped on restart.
TOKEN_ENCRYPTION_KEY: Optional base64-encoded Fernet key (required for token storage)
Returns:
RefreshTokenStorage instance
Note:
If TOKEN_ENCRYPTION_KEY is not set, token storage operations will fail,
but webhook tracking will still work.
"""
database_url = get_database_url()
if is_sqlite_url(database_url):
sqlite_path = database_url.split("///", 1)[1]
if is_ephemeral_token_db(sqlite_path):
logger.info(
"Using ephemeral token storage at %s "
"(set DATABASE_URL or TOKEN_STORAGE_DB to persist tokens across restarts)",
sqlite_path,
)
else:
logger.info(
"Using centralized token storage at %s", mask_db_password(database_url)
)
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
encryption_key = None
if encryption_key_b64:
# Fernet expects a base64url-encoded key as bytes, not decoded bytes
# The key from Fernet.generate_key() is already base64url-encoded
try:
# Convert string to bytes if needed
if isinstance(encryption_key_b64, str):
encryption_key = encryption_key_b64.encode()
else:
encryption_key = encryption_key_b64
# Validate the key by trying to create a Fernet instance
Fernet(encryption_key)
except Exception as e:
raise ValueError(
f"Invalid TOKEN_ENCRYPTION_KEY: {e}. "
"Must be a valid Fernet key (base64url-encoded 32 bytes)."
) from e
else:
logger.info(
"TOKEN_ENCRYPTION_KEY not set - token storage operations will be unavailable, "
"but webhook tracking will still work"
)
return cls(database_url=database_url, encryption_key=encryption_key)
async def initialize(self) -> None:
"""
Initialize database schema using Alembic migrations.
This method handles three scenarios:
1. New database: Run migrations from scratch
2. Pre-Alembic database: Stamp with initial revision (no changes)
3. Alembic-managed database: Upgrade to latest version
Raises:
RuntimeError: when the underlying SQLite library is older than
3.35, which is required for ``DELETE ... RETURNING`` used by
``delete_browser_session`` (PR #758 round-5 review low 2).
Ubuntu 20.04 ships SQLite 3.31, so deployers on that
baseline must upgrade or use a newer Python image.
"""
if self._initialized:
return
is_sqlite = is_sqlite_url(self.database_url)
if is_sqlite and sqlite3.sqlite_version_info < (3, 35):
raise RuntimeError(
"SQLite >= 3.35 is required (DELETE ... RETURNING is used "
"by delete_browser_session); detected "
f"{sqlite3.sqlite_version}. Upgrade SQLite or use a Python "
"image with a newer bundled libsqlite3."
)
if is_sqlite:
# File-permission hardening + parent dir creation is sqlite-only;
# centralized backends manage their own filesystem.
db_dir = Path(self.db_path).parent
db_dir.mkdir(parents=True, exist_ok=True)
if Path(self.db_path).exists():
os.chmod(self.db_path, 0o600)
# Create the shared async engine for the chosen backend. SQLite uses
# NullPool (per-call connections, matches the prior aiosqlite-direct
# behavior); Postgres uses the default pool with pre-ping so dropped
# connections from idle k8s networks are retried transparently.
if is_sqlite:
self.engine = create_async_engine(
self.database_url,
poolclass=NullPool,
connect_args={"check_same_thread": False},
future=True,
)
else:
# Postgres ships as an optional PyPI extra (`[postgres]`) so the
# default `pip install nextcloud-mcp-server` audience doesn't
# pull in asyncpg's C extension. The Docker image bundles it.
# Surface a clear actionable error when the driver is missing
# rather than the generic ModuleNotFoundError SQLAlchemy emits.
if "+asyncpg" in self.database_url.lower() and (
importlib.util.find_spec("asyncpg") is None
):
raise RuntimeError(
"DATABASE_URL points at Postgres via asyncpg but the "
"'asyncpg' driver is not installed. Install with "
"`pip install nextcloud-mcp-server[postgres]` or use "
"the Docker image, which bundles it. See ADR-026."
)
# Conditionally pass TLS config through to asyncpg. When
# get_database_ssl() returns None we omit ``ssl`` entirely so
# asyncpg's default (``prefer``) applies — keeps cluster-local
# Postgres without TLS working out of the box. See ADR-026.
connect_args: dict[str, object] = {}
ssl_arg = get_database_ssl()
if ssl_arg is not None:
connect_args["ssl"] = ssl_arg
logger.info(
"Postgres backend TLS: %s",
"disabled"
if ssl_arg is False
else "custom CA bundle"
if not isinstance(ssl_arg, bool)
else "verify-full (system CAs)",
)
settings = get_settings()
self.engine = create_async_engine(
self.database_url,
pool_size=settings.database_pool_size,
max_overflow=settings.database_max_overflow,
pool_pre_ping=True,
connect_args=connect_args,
future=True,
)
self._dialect = self.engine.dialect.name
# Check database state with the SQLAlchemy inspector so the legacy
# ``sqlite_master`` lookup works against either backend.
def _inspect(sync_conn: sa.Connection) -> tuple[bool, bool]:
insp = sa.inspect(sync_conn)
tables = set(insp.get_table_names())
return ("alembic_version" in tables), ("refresh_tokens" in tables)
async with self.engine.connect() as conn:
has_alembic, has_schema = await conn.run_sync(_inspect)
if not has_alembic:
if has_schema:
logger.info(
"Detected pre-Alembic database at %s, stamping with initial revision",
mask_db_password(self.database_url),
)
await to_thread.run_sync(stamp_database, self.database_url, "001")
logger.info(
"Pre-Alembic database stamped successfully. "
"Future schema changes will use migrations."
)
else:
logger.info(
"Initializing new database at %s with migrations",
mask_db_password(self.database_url),
)
await to_thread.run_sync(upgrade_database, self.database_url, "head")
logger.info("Database initialized with migrations")
else:
await to_thread.run_sync(upgrade_database, self.database_url, "head")
logger.info("Database upgraded to latest version")
if is_sqlite:
os.chmod(self.db_path, 0o600)
self._initialized = True
logger.info(
"Initialized refresh token storage at %s",
mask_db_password(self.database_url),
)
@asynccontextmanager
async def _db(self):
"""Open a backend-agnostic connection.
Yields a :class:`_DBConn` that mimics aiosqlite's API (``execute`` with
``?`` placeholders, ``commit``, cursor with ``fetchone`` /
``fetchall`` / ``rowcount``) so the existing storage method bodies
work against either SQLite or Postgres without per-call rewrites.
"""
assert self.engine is not None, "RefreshTokenStorage.initialize() not called"
async with self.engine.connect() as conn:
yield _DBConn(conn)
async def store_refresh_token(
self,
user_id: str,
refresh_token: str,
expires_at: int | None = None,
flow_type: str = "hybrid",
token_audience: str = "nextcloud",
provisioning_client_id: str | None = None,
scopes: list[str] | None = None,
) -> None:
"""
Store encrypted refresh token for user.
Args:
user_id: User identifier (from OIDC 'sub' claim)
refresh_token: Refresh token to store
expires_at: Token expiration timestamp (Unix epoch), if known
flow_type: Type of flow ('hybrid', 'flow1', 'flow2')
token_audience: Token audience ('mcp-server' or 'nextcloud')
provisioning_client_id: Client ID that initiated Flow 1
scopes: List of granted scopes
"""
if not self._initialized:
await self.initialize()
# ``assert`` is stripped under ``python -O``, which would silently
# turn a missing TOKEN_ENCRYPTION_KEY into an ``AttributeError`` on
# the next ``self.cipher.encrypt(...)``. Raise explicitly instead
# (PR #758 round-4 review medium 1).
if self.cipher is None:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set — token storage operations unavailable"
)
encrypted_token = self.cipher.encrypt(refresh_token.encode())
now = int(time.time())
scopes_json = json.dumps(scopes) if scopes else None
# For Flow 2, set provisioned_at timestamp
provisioned_at = now if flow_type == "flow2" else None
start_time = time.time()
try:
async with self._db() as db:
# ON CONFLICT DO UPDATE preserves ``created_at`` (it's not
# listed in the update clause) so the original
# COALESCE(...)-based INSERT OR REPLACE semantics are kept.
await db.execute(
"""
INSERT INTO refresh_tokens
(user_id, encrypted_token, expires_at, created_at, updated_at,
flow_type, token_audience, provisioned_at, provisioning_client_id, scopes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET
encrypted_token = EXCLUDED.encrypted_token,
expires_at = EXCLUDED.expires_at,
updated_at = EXCLUDED.updated_at,
flow_type = EXCLUDED.flow_type,
token_audience = EXCLUDED.token_audience,
provisioned_at = EXCLUDED.provisioned_at,
provisioning_client_id = EXCLUDED.provisioning_client_id,
scopes = EXCLUDED.scopes
""",
(
user_id,
encrypted_token,
expires_at,
now,
now,
flow_type,
token_audience,
provisioned_at,
provisioning_client_id,
scopes_json,
),
)
await db.commit()
duration = time.time() - start_time
record_db_operation(self._dialect, "insert", duration, "success")
logger.info(
f"Stored refresh token for user {user_id}"
+ (f" (expires at {expires_at})" if expires_at else "")
)
except Exception:
duration = time.time() - start_time
record_db_operation(self._dialect, "insert", duration, "error")
raise
# Audit log
await self._audit_log(
event="store_refresh_token",
user_id=user_id,
auth_method="offline_access",
)
async def store_user_profile(
self, user_id: str, profile_data: dict[str, Any]
) -> None:
"""
Store user profile data (cached from IdP userinfo endpoint).
This profile is cached ONLY for browser UI display purposes, not for
authorization decisions. Background jobs should NOT rely on this data.
Args:
user_id: User identifier (must match refresh_tokens.user_id)
profile_data: User profile dict from IdP userinfo endpoint
"""
if not self._initialized:
await self.initialize()
profile_json = json.dumps(profile_data)
now = int(time.time())
async with self._db() as db:
await db.execute(
"""
UPDATE refresh_tokens
SET user_profile = ?, profile_cached_at = ?
WHERE user_id = ?
""",
(profile_json, now, user_id),
)
await db.commit()
logger.debug("Cached user profile for %s", user_id)
async def get_user_profile(self, user_id: str) -> dict[str, Any] | None:
"""
Retrieve cached user profile data.
This returns cached profile data from the initial OAuth login,
NOT fresh data from the IdP. Use this for browser UI display only.
Args:
user_id: User identifier
Returns:
User profile dict or None if not cached
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
async with db.execute(
"""
SELECT user_profile, profile_cached_at
FROM refresh_tokens
WHERE user_id = ?
""",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row or not row[0]:
return None
profile_json, cached_at = row
profile_data = json.loads(profile_json)
# Optionally add cache metadata
profile_data["_cached_at"] = cached_at
return profile_data
async def get_refresh_token(self, user_id: str) -> dict | None:
"""
Retrieve and decrypt refresh token for user.
Args:
user_id: User identifier
Returns:
Dictionary with token data including ADR-004 fields:
{
"refresh_token": str,
"expires_at": int | None,
"flow_type": str,
"token_audience": str,
"provisioned_at": int | None,
"provisioning_client_id": str | None,
"scopes": list[str] | None
}
or None if not found or expired
"""
if not self._initialized:
await self.initialize()
# ``assert`` is stripped under ``python -O``, which would silently
# turn a missing TOKEN_ENCRYPTION_KEY into an ``AttributeError`` on
# the next ``self.cipher.encrypt(...)``. Raise explicitly instead
# (PR #758 round-4 review medium 1).
if self.cipher is None:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set — token storage operations unavailable"
)
start_time = time.time()
try:
async with self._db() as db:
async with db.execute(
"""
SELECT encrypted_token, expires_at, flow_type, token_audience,
provisioned_at, provisioning_client_id, scopes
FROM refresh_tokens WHERE user_id = ?
""",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug("No refresh token found for user %s", user_id)
duration = time.time() - start_time
record_db_operation(self._dialect, "select", duration, "success")
return None
(
encrypted_token,
expires_at,
flow_type,
token_audience,
provisioned_at,
provisioning_client_id,
scopes_json,
) = row
# Check expiration
if expires_at is not None and expires_at < time.time():
logger.warning(
"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
record_db_operation(self._dialect, "select", duration, "success")
return None
decrypted_token = self.cipher.decrypt(encrypted_token).decode()
scopes = json.loads(scopes_json) if scopes_json else None
logger.debug(
"Retrieved refresh token for user %s (flow_type: %s)",
user_id,
flow_type,
)
duration = time.time() - start_time
record_db_operation(self._dialect, "select", duration, "success")
return {
"refresh_token": decrypted_token,
"expires_at": expires_at,
"flow_type": flow_type or "hybrid", # Default for existing tokens
"token_audience": token_audience
or "nextcloud", # Default for existing tokens
"provisioned_at": provisioned_at,
"provisioning_client_id": provisioning_client_id,
"scopes": scopes,
}
except Exception as e:
duration = time.time() - start_time
record_db_operation(self._dialect, "select", duration, "error")
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(
self, provisioning_client_id: str
) -> dict | None:
"""
Retrieve and decrypt refresh token by provisioning_client_id (state parameter).
This is used to check if an OAuth Flow 2 login completed successfully
by looking up the refresh token using the state parameter that was generated
during the authorization request.
Args:
provisioning_client_id: OAuth state parameter from the authorization request
Returns:
Dictionary with token data or None if not found
"""
if not self._initialized:
await self.initialize()
# ``assert`` is stripped under ``python -O``, which would silently
# turn a missing TOKEN_ENCRYPTION_KEY into an ``AttributeError`` on
# the next ``self.cipher.encrypt(...)``. Raise explicitly instead
# (PR #758 round-4 review medium 1).
if self.cipher is None:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set — token storage operations unavailable"
)
async with self._db() as db:
async with db.execute(
"""
SELECT user_id, encrypted_token, expires_at, flow_type, token_audience,
provisioned_at, provisioning_client_id, scopes
FROM refresh_tokens WHERE provisioning_client_id = ?
""",
(provisioning_client_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug(
"No refresh token found for provisioning_client_id %s...",
provisioning_client_id[:16],
)
return None
(
user_id,
encrypted_token,
expires_at,
flow_type,
token_audience,
provisioned_at,
prov_client_id,
scopes_json,
) = row
# Check expiration
if expires_at is not None and expires_at < time.time():
logger.warning(
"Refresh token for provisioning_client_id %s... has expired",
provisioning_client_id[:16],
)
return None
try:
decrypted_token = self.cipher.decrypt(encrypted_token).decode()
scopes = json.loads(scopes_json) if scopes_json else None
logger.debug(
"Retrieved refresh token for provisioning_client_id %s... (user_id: %s)",
provisioning_client_id[:16],
user_id,
)
return {
"user_id": user_id,
"refresh_token": decrypted_token,
"expires_at": expires_at,
"flow_type": flow_type or "hybrid",
"token_audience": token_audience or "nextcloud",
"provisioned_at": provisioned_at,
"provisioning_client_id": prov_client_id,
"scopes": scopes,
}
except Exception as e:
logger.error(
"Failed to decrypt refresh token for provisioning_client_id %s...: %s",
provisioning_client_id[:16],
e,
)
return None
async def delete_refresh_token(self, user_id: str) -> bool:
"""
Delete refresh token for user.
Args:
user_id: User identifier
Returns:
True if token was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
start_time = time.time()
try:
async with self._db() as db:
cursor = await db.execute(
"DELETE FROM refresh_tokens WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation(self._dialect, "delete", duration, "success")
if deleted:
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("No refresh token to delete for user %s", user_id)
return deleted
except Exception:
duration = time.time() - start_time
record_db_operation(self._dialect, "delete", duration, "error")
raise
async def get_all_user_ids(self) -> list[str]:
"""
Get list of all user IDs with stored refresh tokens.
Returns:
List of user IDs
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
async with db.execute(
"SELECT user_id FROM refresh_tokens ORDER BY updated_at DESC"
) as cursor:
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows]
logger.debug("Found %s users with refresh tokens", len(user_ids))
return user_ids
async def cleanup_expired_tokens(self) -> int:
"""
Remove expired refresh tokens from storage.
Returns:
Number of tokens deleted
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
async with self._db() as db:
cursor = await db.execute(
"DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < ?",
(now,),
)
await db.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.info("Cleaned up %s expired refresh token(s)", deleted)
return deleted
async def store_oauth_client(
self,
client_id: str,
client_secret: str,
client_id_issued_at: int,
client_secret_expires_at: int,
redirect_uris: list[str],
registration_access_token: str | None = None,
registration_client_uri: str | None = None,
) -> None:
"""
Store encrypted OAuth client credentials.
Args:
client_id: OAuth client identifier
client_secret: OAuth client secret (will be encrypted)
client_id_issued_at: Unix timestamp when client was issued
client_secret_expires_at: Unix timestamp when secret expires
redirect_uris: List of redirect URIs
registration_access_token: RFC 7592 registration token (will be encrypted)
registration_client_uri: RFC 7592 client management URI
"""
if not self._initialized:
await self.initialize()
# ``assert`` is stripped under ``python -O``, which would silently
# turn a missing TOKEN_ENCRYPTION_KEY into an ``AttributeError`` on
# the next ``self.cipher.encrypt(...)``. Raise explicitly instead
# (PR #758 round-4 review medium 1).
if self.cipher is None:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set — token storage operations unavailable"
)
# Encrypt sensitive data
encrypted_secret = self.cipher.encrypt(client_secret.encode())
encrypted_reg_token = (
self.cipher.encrypt(registration_access_token.encode())
if registration_access_token
else None
)
# Serialize redirect_uris as JSON
redirect_uris_json = json.dumps(redirect_uris)
now = int(time.time())
async with self._db() as db:
# Singleton row pinned at id=1; ON CONFLICT preserves the
# original ``created_at`` because it's omitted from the update.
await db.execute(
"""
INSERT INTO oauth_clients
(id, client_id, encrypted_client_secret, client_id_issued_at,
client_secret_expires_at, redirect_uris, encrypted_registration_access_token,
registration_client_uri, created_at, updated_at)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
client_id = EXCLUDED.client_id,
encrypted_client_secret = EXCLUDED.encrypted_client_secret,
client_id_issued_at = EXCLUDED.client_id_issued_at,
client_secret_expires_at = EXCLUDED.client_secret_expires_at,
redirect_uris = EXCLUDED.redirect_uris,
encrypted_registration_access_token = EXCLUDED.encrypted_registration_access_token,
registration_client_uri = EXCLUDED.registration_client_uri,
updated_at = EXCLUDED.updated_at
""",
(
client_id,
encrypted_secret,
client_id_issued_at,
client_secret_expires_at,
redirect_uris_json,
encrypted_reg_token,
registration_client_uri,
now,
now,
),
)
await db.commit()
logger.info(
"Stored OAuth client credentials (client_id: %s..., expires at %s)",
client_id[:16],
client_secret_expires_at,
)
# Audit log
await self._audit_log(
event="store_oauth_client",
user_id="system",
auth_method="oauth",
)
async def get_oauth_client(self) -> dict | None:
"""
Retrieve and decrypt OAuth client credentials.
Returns:
Dictionary with client credentials, or None if not found or expired:
{
"client_id": str,
"client_secret": str,
"client_id_issued_at": int,
"client_secret_expires_at": int,
"redirect_uris": list[str],
"registration_access_token": str | None,
"registration_client_uri": str | None,
}
"""
if not self._initialized:
await self.initialize()
# ``assert`` is stripped under ``python -O``, which would silently
# turn a missing TOKEN_ENCRYPTION_KEY into an ``AttributeError`` on
# the next ``self.cipher.encrypt(...)``. Raise explicitly instead
# (PR #758 round-4 review medium 1).
if self.cipher is None:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set — token storage operations unavailable"
)
async with self._db() as db:
async with db.execute(
"""
SELECT client_id, encrypted_client_secret, client_id_issued_at,
client_secret_expires_at, redirect_uris,
encrypted_registration_access_token, registration_client_uri
FROM oauth_clients WHERE id = 1
"""
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug("No OAuth client credentials found in storage")
return None
(
client_id,
encrypted_secret,
issued_at,
expires_at,
redirect_uris_json,
encrypted_reg_token,
reg_client_uri,
) = row
# Check expiration
if expires_at < time.time():
logger.warning(
"OAuth client has expired (expired at %s), deleting", expires_at
)
await self.delete_oauth_client()
return None
try:
# Decrypt sensitive data
client_secret = self.cipher.decrypt(encrypted_secret).decode()
reg_token = (
self.cipher.decrypt(encrypted_reg_token).decode()
if encrypted_reg_token
else None
)
# Deserialize redirect_uris
redirect_uris = json.loads(redirect_uris_json)
logger.debug(
"Retrieved OAuth client credentials (client_id: %s...)", client_id[:16]
)
return {
"client_id": client_id,
"client_secret": client_secret,
"client_id_issued_at": issued_at,
"client_secret_expires_at": expires_at,
"redirect_uris": redirect_uris,
"registration_access_token": reg_token,
"registration_client_uri": reg_client_uri,
}
except Exception as e:
logger.error("Failed to decrypt OAuth client credentials: %s", e)
return None
async def delete_oauth_client(self) -> bool:
"""
Delete OAuth client credentials.
Returns:
True if client was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
cursor = await db.execute("DELETE FROM oauth_clients WHERE id = 1")
await db.commit()
deleted = cursor.rowcount > 0
if deleted:
logger.info("Deleted OAuth client credentials from storage")
await self._audit_log(
event="delete_oauth_client",
user_id="system",
auth_method="oauth",
)
else:
logger.debug("No OAuth client credentials to delete")
return deleted
async def has_oauth_client(self) -> bool:
"""
Check if OAuth client credentials exist (and are not expired).
Returns:
True if valid client exists, False otherwise
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
async with db.execute(
"SELECT client_secret_expires_at FROM oauth_clients WHERE id = 1"
) as cursor:
row = await cursor.fetchone()
if not row:
return False
expires_at = row[0]
return expires_at >= time.time()
async def _audit_log(
self,
event: str,
user_id: str,
resource_type: str | None = None,
resource_id: str | None = None,
auth_method: str | None = None,
) -> None:
"""
Log operation to audit log.
Args:
event: Event name (e.g., "store_refresh_token", "token_refresh")
user_id: User identifier
resource_type: Resource type (e.g., "note", "file")
resource_id: Resource identifier
auth_method: Authentication method used
"""
hostname = socket.gethostname()
timestamp = int(time.time())
async with self._db() as db:
await db.execute(
"""
INSERT INTO audit_logs
(timestamp, event, user_id, resource_type, resource_id, auth_method, hostname)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
timestamp,
event,
user_id,
resource_type,
resource_id,
auth_method,
hostname,
),
)
await db.commit()
async def get_audit_logs(
self,
user_id: str | None = None,
since: int | None = None,
limit: int = 100,
) -> list[dict]:
"""
Retrieve audit logs.
Args:
user_id: Filter by user ID (optional)
since: Filter by timestamp (Unix epoch, optional)
limit: Maximum number of logs to return
Returns:
List of audit log entries
"""
if not self._initialized:
await self.initialize()
query = "SELECT * FROM audit_logs WHERE 1=1"
params = []
if user_id:
query += " AND user_id = ?"
params.append(user_id)
if since:
query += " AND timestamp >= ?"
params.append(since)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
async with self._db() as db:
async with db.execute(query, params) as cursor:
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def store_oauth_session(
self,
session_id: str,
client_redirect_uri: str,
state: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
mcp_authorization_code: str | None = None,
client_id: str | None = None,
flow_type: str = "hybrid",
is_provisioning: bool = False,
requested_scopes: str | None = None,
nonce: str | None = None,
ttl_seconds: int = 600, # 10 minutes
) -> None:
"""
Store OAuth session for ADR-004 Progressive Consent.
Args:
session_id: Unique session identifier
client_redirect_uri: Client's localhost redirect URI
state: CSRF protection state parameter
code_challenge: PKCE code challenge
code_challenge_method: PKCE method (S256)
mcp_authorization_code: Pre-generated MCP authorization code
client_id: Client identifier (for Flow 1)
flow_type: Type of flow ('hybrid', 'flow1', 'flow2')
is_provisioning: Whether this is a Flow 2 provisioning session
requested_scopes: Requested OAuth scopes
nonce: OIDC ``nonce`` value bound to this auth request, returned
in the ID token and verified on callback (PR #758 finding 2).
ttl_seconds: Session TTL in seconds
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
expires_at = now + ttl_seconds
async with self._db() as db:
await db.execute(
"""
INSERT INTO oauth_sessions
(session_id, client_id, client_redirect_uri, state, code_challenge,
code_challenge_method, mcp_authorization_code, flow_type,
is_provisioning, requested_scopes, nonce, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
session_id,
client_id,
client_redirect_uri,
state,
code_challenge,
code_challenge_method,
mcp_authorization_code,
flow_type,
is_provisioning,
requested_scopes,
nonce,
now,
expires_at,
),
)
await db.commit()
logger.debug(
"Stored OAuth session %s (expires in %ss)", session_id, ttl_seconds
)
async def get_oauth_session(self, session_id: str) -> dict | None:
"""
Retrieve OAuth session by session ID.
Returns:
Session dictionary or None if not found/expired
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
async with db.execute(
"SELECT * FROM oauth_sessions WHERE session_id = ?", (session_id,)
) as cursor:
row = await cursor.fetchone()
if not row:
return None
session = dict(row)
# Check expiration
if session["expires_at"] < time.time():
logger.debug("OAuth session %s has expired", session_id)
await self.delete_oauth_session(session_id)
return None
return session
async def get_oauth_session_by_mcp_code(
self, mcp_authorization_code: str
) -> dict | None:
"""
Retrieve OAuth session by MCP authorization code.
Returns:
Session dictionary or None if not found/expired
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
async with db.execute(
"SELECT * FROM oauth_sessions WHERE mcp_authorization_code = ?",
(mcp_authorization_code,),
) as cursor:
row = await cursor.fetchone()
if not row:
return None
session = dict(row)
# Check expiration
if session["expires_at"] < time.time():
logger.debug(
"OAuth session with MCP code %s... has expired",
mcp_authorization_code[:16],
)
await self.delete_oauth_session(session["session_id"])
return None
return session
async def update_oauth_session(
self,
session_id: str,
user_id: str | None = None,
idp_access_token: str | None = None,
idp_refresh_token: str | None = None,
) -> bool:
"""
Update OAuth session with IdP token data.
Returns:
True if session was updated, False if not found
"""
if not self._initialized:
await self.initialize()
update_fields = []
params = []
if user_id is not None:
update_fields.append("user_id = ?")
params.append(user_id)
if idp_access_token is not None:
update_fields.append("idp_access_token = ?")
params.append(idp_access_token)
if idp_refresh_token is not None:
update_fields.append("idp_refresh_token = ?")
params.append(idp_refresh_token)
if not update_fields:
return False
params.append(session_id)
async with self._db() as db:
cursor = await db.execute(
f"""
UPDATE oauth_sessions
SET {", ".join(update_fields)}
WHERE session_id = ?
""",
params,
)
await db.commit()
updated = cursor.rowcount > 0
if updated:
logger.debug("Updated OAuth session %s", session_id)
return updated
async def delete_oauth_session(self, session_id: str) -> bool:
"""
Delete OAuth session.
Returns:
True if session was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
cursor = await db.execute(
"DELETE FROM oauth_sessions WHERE session_id = ?", (session_id,)
)
await db.commit()
deleted = cursor.rowcount > 0
if deleted:
logger.debug("Deleted OAuth session %s", session_id)
return deleted
async def cleanup_expired_sessions(self) -> int:
"""
Remove expired OAuth sessions from storage.
Returns:
Number of sessions deleted
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
async with self._db() as db:
cursor = await db.execute(
"DELETE FROM oauth_sessions WHERE expires_at < ?", (now,)
)
await db.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.info("Cleaned up %s expired OAuth session(s)", deleted)
return deleted
# ============================================================================
# Browser Sessions (OAuth admin UI)
# ============================================================================
#
# Maps a cryptographically random `session_id` (cookie value) to the
# authenticated user_id. Replaces the prior `mcp_session=<user_id>`
# cookie pattern (issue #626 finding 2). Cookie value is opaque, expires,
# and can be revoked server-side without forcing the user to roll their
# IdP `sub`.
async def create_browser_session(
self,
session_id: str,
user_id: str,
ttl_seconds: int = 86400 * 30,
) -> None:
"""Persist a random session_id → user_id mapping for browser auth."""
if not self._initialized:
await self.initialize()
now = int(time.time())
expires_at = now + ttl_seconds
async with self._db() as db:
await db.execute(
"""
INSERT INTO browser_sessions
(session_id, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (session_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
created_at = EXCLUDED.created_at,
expires_at = EXCLUDED.expires_at
""",
(session_id, user_id, now, expires_at),
)
await db.commit()
logger.debug(
"Stored browser session %s for user %s (expires in %ss)",
session_id[:8],
user_id,
ttl_seconds,
)
# Audit log to match the pattern used by the other security-relevant
# storage operations (PR #758 round-3 nit 5). Browser session
# establishment is a security-relevant event.
await self._audit_log(
event="create_browser_session",
user_id=user_id,
resource_type="browser_session",
resource_id=session_id[:8],
)
async def get_browser_session_user(self, session_id: str) -> str | None:
"""Look up the user_id bound to a browser session_id, or None.
Returns None when the session is unknown or expired. Expired rows
are deleted on encounter to keep the table small.
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
async with db.execute(
"SELECT user_id, expires_at FROM browser_sessions WHERE session_id = ?",
(session_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
return None
if row["expires_at"] < time.time():
logger.debug("Browser session %s expired", session_id[:8])
await self.delete_browser_session(session_id)
return None
return row["user_id"]
async def delete_browser_session(self, session_id: str) -> bool:
"""Delete a browser session row. Returns True when a row was removed."""
if not self._initialized:
await self.initialize()
# DELETE ... RETURNING (SQLite ≥ 3.35) reads ``user_id`` atomically
# with the delete itself, so the audit log can't race against a
# concurrent delete that empties the row between SELECT and DELETE
# (PR #758 round-3 review).
user_id: str | None = None
async with self._db() as db:
async with db.execute(
"DELETE FROM browser_sessions WHERE session_id = ? RETURNING user_id",
(session_id,),
) as cursor:
row = await cursor.fetchone()
await db.commit()
deleted = row is not None
if deleted:
user_id = row[0]
logger.debug("Deleted browser session %s", session_id[:8])
if user_id:
await self._audit_log(
event="delete_browser_session",
user_id=user_id,
resource_type="browser_session",
resource_id=session_id[:8],
)
return deleted
async def cleanup_expired_browser_sessions(self) -> int:
"""Remove expired ``browser_sessions`` rows.
Returns the number of rows deleted. Called by the periodic cleanup
task in ``app.py``. Without this users who never explicitly log out
leave session rows behind that only get deleted lazily on lookup
(PR #758 finding 6).
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
async with self._db() as db:
cursor = await db.execute(
"DELETE FROM browser_sessions WHERE expires_at < ?", (now,)
)
await db.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.info("Cleaned up %s expired browser session(s)", deleted)
return deleted
# ============================================================================
# Webhook Registration Tracking (both BasicAuth and OAuth modes)
# ============================================================================
async def store_webhook(self, webhook_id: int, preset_id: str) -> None:
"""
Store registered webhook ID for tracking.
Args:
webhook_id: Nextcloud webhook ID
preset_id: Preset identifier (e.g., "notes_sync", "calendar_sync")
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
await db.execute(
"""
INSERT INTO registered_webhooks (webhook_id, preset_id, created_at)
VALUES (?, ?, ?)
ON CONFLICT (webhook_id) DO UPDATE SET
preset_id = EXCLUDED.preset_id,
created_at = EXCLUDED.created_at
""",
(webhook_id, preset_id, int(time.time())),
)
await db.commit()
logger.debug("Stored webhook %s for preset '%s'", webhook_id, preset_id)
async def get_webhooks_by_preset(self, preset_id: str) -> list[int]:
"""
Get all webhook IDs registered for a preset.
Args:
preset_id: Preset identifier
Returns:
List of webhook IDs
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
cursor = await db.execute(
"SELECT webhook_id FROM registered_webhooks WHERE preset_id = ?",
(preset_id,),
)
rows = await cursor.fetchall()
return [row[0] for row in rows]
async def delete_webhook(self, webhook_id: int) -> bool:
"""
Remove webhook from tracking.
Args:
webhook_id: Nextcloud webhook ID to remove
Returns:
True if webhook was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
cursor = await db.execute(
"DELETE FROM registered_webhooks WHERE webhook_id = ?", (webhook_id,)
)
await db.commit()
deleted = cursor.rowcount > 0
if deleted:
logger.debug("Deleted webhook %s from tracking", webhook_id)
return deleted
async def list_all_webhooks(self) -> list[dict]:
"""
List all tracked webhooks with metadata.
Returns:
List of webhook dictionaries with keys: webhook_id, preset_id, created_at
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
cursor = await db.execute(
"SELECT webhook_id, preset_id, created_at FROM registered_webhooks ORDER BY created_at DESC"
)
rows = await cursor.fetchall()
return [
{"webhook_id": row[0], "preset_id": row[1], "created_at": row[2]}
for row in rows
]
async def clear_preset_webhooks(self, preset_id: str) -> int:
"""
Delete all webhooks for a preset (bulk operation).
Args:
preset_id: Preset identifier
Returns:
Number of webhooks deleted
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
cursor = await db.execute(
"DELETE FROM registered_webhooks WHERE preset_id = ?", (preset_id,)
)
await db.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.debug("Cleared %s webhook(s) for preset '%s'", deleted, preset_id)
return deleted
# ============================================================================
# App Password Storage (multi-user BasicAuth mode)
# ============================================================================
async def store_app_password(
self,
user_id: str,
app_password: str,
) -> None:
"""
Store encrypted app password for background sync (multi-user BasicAuth mode).
Args:
user_id: Nextcloud user ID
app_password: Nextcloud app password to store
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password storage."
)
encrypted_password = self.cipher.encrypt(app_password.encode())
now = int(time.time())
start_time = time.time()
try:
async with self._db() as db:
await db.execute(
"""
INSERT INTO app_passwords
(user_id, encrypted_password, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET
encrypted_password = EXCLUDED.encrypted_password,
updated_at = EXCLUDED.updated_at
""",
(user_id, encrypted_password, now, now),
)
await db.commit()
duration = time.time() - start_time
record_db_operation(self._dialect, "insert", duration, "success")
logger.info("Stored app password for user %s", user_id)
except Exception:
duration = time.time() - start_time
record_db_operation(self._dialect, "insert", duration, "error")
raise
# Audit log
await self._audit_log(
event="store_app_password",
user_id=user_id,
auth_method="app_password",
)
async def get_app_password(self, user_id: str) -> str | None:
"""
Retrieve and decrypt app password for a user.
Args:
user_id: Nextcloud user ID
Returns:
Decrypted app password, or None if not found
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
)
start_time = time.time()
try:
async with self._db() as db:
async with db.execute(
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug("No app password found for user %s", user_id)
duration = time.time() - start_time
record_db_operation(self._dialect, "select", duration, "success")
return None
encrypted_password = row[0]
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
duration = time.time() - start_time
record_db_operation(self._dialect, "select", duration, "success")
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(self._dialect, "select", duration, "error")
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:
"""
Delete app password for a user.
Args:
user_id: Nextcloud user ID
Returns:
True if password was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
start_time = time.time()
try:
async with self._db() as db:
cursor = await db.execute(
"DELETE FROM app_passwords WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation(self._dialect, "delete", duration, "success")
if deleted:
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("No app password to delete for user %s", user_id)
return deleted
except Exception:
duration = time.time() - start_time
record_db_operation(self._dialect, "delete", duration, "error")
raise
async def get_all_app_password_user_ids(self) -> list[str]:
"""
Get list of all user IDs with stored app passwords.
Returns:
List of user IDs
"""
if not self._initialized:
await self.initialize()
async with self._db() as db:
async with db.execute(
"SELECT user_id FROM app_passwords ORDER BY updated_at DESC"
) as cursor:
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows]
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]:
"""
Validate stored app passwords against Nextcloud and remove invalid ones.
Makes a lightweight OCS request for each stored user to check if credentials
are still valid. Removes entries that return 401/403.
Args:
nextcloud_host: Nextcloud base URL
Returns:
List of user IDs whose app passwords were removed
"""
if not self._initialized:
await self.initialize()
user_ids = await self.get_all_app_password_user_ids()
if not user_ids:
return []
removed: list[str] = []
async def _validate_user(user_id: str) -> None:
app_password = await self.get_app_password(user_id)
if not app_password:
return
try:
async with httpx.AsyncClient(
base_url=nextcloud_host,
auth=httpx.BasicAuth(user_id, app_password),
timeout=10.0,
) as client:
response = await client.get(
"/ocs/v2.php/cloud/user",
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
},
)
if response.status_code in (401, 403):
logger.info(
"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(
"App password for %s validated (HTTP %s)",
user_id,
response.status_code,
)
except Exception as 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:
tg.start_soon(_validate_user, user_id)
return removed
# ── Login Flow v2: Scoped App Passwords ──────────────────────────────
async def store_app_password_with_scopes(
self,
user_id: str,
app_password: str,
scopes: list[str] | None = None,
username: str | None = None,
) -> None:
"""Store encrypted app password with optional scopes and Nextcloud username.
Args:
user_id: MCP user ID (identity from OAuth token or session)
app_password: Nextcloud app password to encrypt and store
scopes: List of granted scopes (None = all scopes allowed)
username: Nextcloud loginName from Login Flow v2 response
Raises:
ValueError: If any scope is not in ALL_SUPPORTED_SCOPES
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password storage."
)
# Defense-in-depth: validate scopes at storage layer
if scopes is not None:
from nextcloud_mcp_server.models.auth import ( # noqa: PLC0415
ALL_SUPPORTED_SCOPES,
)
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid:
raise ValueError(f"Invalid scopes: {invalid}")
encrypted_password = self.cipher.encrypt(app_password.encode())
scopes_json = json.dumps(scopes) if scopes is not None else None
now = int(time.time())
start_time = time.time()
try:
async with self._db() as db:
await db.execute(
"""
INSERT INTO app_passwords
(user_id, encrypted_password, created_at, updated_at, scopes, username)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET
encrypted_password = EXCLUDED.encrypted_password,
updated_at = EXCLUDED.updated_at,
scopes = EXCLUDED.scopes,
username = EXCLUDED.username
""",
(
user_id,
encrypted_password,
now,
now,
scopes_json,
username,
),
)
await db.commit()
duration = time.time() - start_time
record_db_operation(self._dialect, "insert", duration, "success")
logger.info(
"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:
duration = time.time() - start_time
record_db_operation(self._dialect, "insert", duration, "error")
raise
await self._audit_log(
event="store_app_password_with_scopes",
user_id=user_id,
auth_method="app_password",
)
async def get_app_password_with_scopes(self, user_id: str) -> dict[str, Any] | None:
"""Retrieve app password with scopes and metadata.
Args:
user_id: MCP user ID
Returns:
Dict with keys: app_password, scopes, username, created_at, updated_at
or None if not found
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
)
start_time = time.time()
try:
async with self._db() as db:
async with db.execute(
"""
SELECT encrypted_password, scopes, username, created_at, updated_at
FROM app_passwords WHERE user_id = ?
""",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug("No app password found for user %s", user_id)
duration = time.time() - start_time
record_db_operation(self._dialect, "select", duration, "success")
return None
encrypted_password, scopes_json, username, created_at, updated_at = row
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
scopes = json.loads(scopes_json) if scopes_json else None
duration = time.time() - start_time
record_db_operation(self._dialect, "select", duration, "success")
return {
"app_password": decrypted_password,
"scopes": scopes,
"username": username,
"created_at": created_at,
"updated_at": updated_at,
}
except Exception:
duration = time.time() - start_time
record_db_operation(self._dialect, "select", duration, "error")
raise
async def update_app_password_scopes(self, user_id: str, scopes: list[str]) -> bool:
"""Update only the scopes for an existing app password (no decrypt/re-encrypt).
Args:
user_id: MCP user ID
scopes: New scope list
Returns:
True if a row was updated, False if user not found
"""
if not self._initialized:
await self.initialize()
scopes_json = json.dumps(scopes)
now = int(time.time())
start_time = time.time()
try:
async with self._db() as db:
cursor = await db.execute(
"UPDATE app_passwords SET scopes = ?, updated_at = ? WHERE user_id = ?",
(scopes_json, now, user_id),
)
await db.commit()
updated = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation(self._dialect, "update", duration, "success")
if updated:
await self._audit_log(
event="update_app_password_scopes",
user_id=user_id,
auth_method="app_password",
)
return updated
except Exception:
duration = time.time() - start_time
record_db_operation(self._dialect, "update", duration, "error")
raise
# ── Login Flow v2: Session Tracking ──────────────────────────────────
async def store_login_flow_session(
self,
user_id: str,
poll_token: str,
poll_endpoint: str,
requested_scopes: list[str] | None = None,
expires_at: int | None = None,
) -> None:
"""Store a Login Flow v2 polling session.
Args:
user_id: MCP user ID
poll_token: Token for polling (will be encrypted)
poll_endpoint: URL to poll for completion
requested_scopes: Scopes requested in this flow
expires_at: Expiration timestamp (defaults to 20 minutes from now)
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for login flow session storage."
)
encrypted_token = self.cipher.encrypt(poll_token.encode())
scopes_json = json.dumps(requested_scopes) if requested_scopes else None
now = int(time.time())
if expires_at is None:
expires_at = now + 1200 # 20 minutes default
start_time = time.time()
try:
async with self._db() as db:
await db.execute(
"""
INSERT INTO login_flow_sessions
(user_id, encrypted_poll_token, poll_endpoint, requested_scopes,
created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET
encrypted_poll_token = EXCLUDED.encrypted_poll_token,
poll_endpoint = EXCLUDED.poll_endpoint,
requested_scopes = EXCLUDED.requested_scopes,
created_at = EXCLUDED.created_at,
expires_at = EXCLUDED.expires_at
""",
(
user_id,
encrypted_token,
poll_endpoint,
scopes_json,
now,
expires_at,
),
)
await db.commit()
duration = time.time() - start_time
record_db_operation(self._dialect, "insert", duration, "success")
logger.info("Stored login flow session for user %s", user_id)
except Exception:
duration = time.time() - start_time
record_db_operation(self._dialect, "insert", duration, "error")
raise
async def get_login_flow_session(self, user_id: str) -> dict[str, Any] | None:
"""Retrieve a pending Login Flow v2 session.
Returns None if session doesn't exist or has expired.
Args:
user_id: MCP user ID
Returns:
Dict with keys: poll_token, poll_endpoint, requested_scopes, created_at, expires_at
or None if not found/expired
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for login flow session retrieval."
)
now = int(time.time())
start_time = time.time()
try:
async with self._db() as db:
async with db.execute(
"""
SELECT encrypted_poll_token, poll_endpoint, requested_scopes,
created_at, expires_at
FROM login_flow_sessions
WHERE user_id = ? AND expires_at > ?
""",
(user_id, now),
) as cursor:
row = await cursor.fetchone()
if not row:
duration = time.time() - start_time
record_db_operation(self._dialect, "select", duration, "success")
return None
encrypted_token, poll_endpoint, scopes_json, created_at, expires_at = row
poll_token = self.cipher.decrypt(encrypted_token).decode()
requested_scopes = json.loads(scopes_json) if scopes_json else None
duration = time.time() - start_time
record_db_operation(self._dialect, "select", duration, "success")
return {
"poll_token": poll_token,
"poll_endpoint": poll_endpoint,
"requested_scopes": requested_scopes,
"created_at": created_at,
"expires_at": expires_at,
}
except Exception as e:
duration = time.time() - start_time
record_db_operation(self._dialect, "select", duration, "error")
logger.error(
"Failed to retrieve login flow session for user %s: %s", user_id, e
)
raise
async def delete_login_flow_session(self, user_id: str) -> bool:
"""Delete a Login Flow v2 session.
Args:
user_id: MCP user ID
Returns:
True if session was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
start_time = time.time()
try:
async with self._db() as db:
cursor = await db.execute(
"DELETE FROM login_flow_sessions WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation(self._dialect, "delete", duration, "success")
if deleted:
logger.info("Deleted login flow session for user %s", user_id)
await self._audit_log(
event="delete_login_flow_session",
user_id=user_id,
auth_method="login_flow",
)
return deleted
except Exception:
duration = time.time() - start_time
record_db_operation(self._dialect, "delete", duration, "error")
raise
async def delete_expired_login_flow_sessions(self) -> int:
"""Delete all expired Login Flow v2 sessions.
Returns:
Number of sessions deleted
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
start_time = time.time()
try:
async with self._db() as db:
cursor = await db.execute(
"DELETE FROM login_flow_sessions WHERE expires_at <= ?",
(now,),
)
await db.commit()
count = cursor.rowcount
duration = time.time() - start_time
record_db_operation(self._dialect, "delete", duration, "success")
if count > 0:
logger.info("Cleaned up %s expired login flow sessions", count)
await self._audit_log(
event="delete_expired_login_flow_sessions",
user_id="system",
auth_method="login_flow",
)
return count
except Exception:
duration = time.time() - start_time
record_db_operation(self._dialect, "delete", duration, "error")
raise
_shared_instance: RefreshTokenStorage | None = None
_shared_lock: anyio.Lock = anyio.Lock()
async def get_shared_storage() -> RefreshTokenStorage:
"""Get the process-wide RefreshTokenStorage singleton (lock-protected).
All modules that need storage should use this function instead of
creating their own lazy singletons. The lock ensures thread-safe
initialization on concurrent first-access.
"""
global _shared_instance
async with _shared_lock:
if _shared_instance is None:
_shared_instance = RefreshTokenStorage.from_env()
await _shared_instance.initialize()
return _shared_instance
async def generate_encryption_key() -> str:
"""
Generate a new Fernet encryption key.
Returns:
Base64-encoded encryption key suitable for TOKEN_ENCRYPTION_KEY env var
"""
return Fernet.generate_key().decode()
# Example usage
if __name__ == "__main__":
import anyio
async def main():
# Generate a key for testing
key = await generate_encryption_key()
print(f"Generated encryption key: {key}")
print(f"Set this in your environment: export TOKEN_ENCRYPTION_KEY='{key}'")
anyio.run(main)