Addresses all 8 items in the round-4 bot review plus 4 remaining SonarQube OPEN issues that were silently broken by round 3's malformed NOSONAR markers. NOSONAR syntax fix (clears the remaining 4 OPEN SQ issues) ---------------------------------------------------------- Round 3 used ``# NOSONAR S<rule_key>`` form. SonarQube Python doesn't recognize the rule-key suffix — it treats the whole thing as a malformed suppression directive (S7632) AND lets the underlying rule keep firing (S7503 on ``_Cursor.__aenter__/__aexit__``). Switch every marker to bare ``# NOSONAR``, with the rationale moved into a preceding comment block. Affected sites: - storage.py: ``_Cursor.__aenter__``, ``_Cursor.__aexit__`` - config.py: ``get_database_ssl()`` ``return False`` + ``ssl.create_default_context()`` - test_storage_logging.py: ``SENTINEL_PASSWORD_FRAGMENT`` constant - test_storage_postgres.py: three ``bob_pw_v1`` / ``bob_pw_v2`` / ``carol_pw`` literals Bot 🔴#1 — defensive NOSONAR on get_database_ssl `return False` -------------------------------------------------------------- Bot predicted S4830 fires on the operator-opt-out path. SQ output shows it doesn't currently fire, but bare NOSONAR added defensively with rationale comment. Bot 🔴#2 — defensive NOSONAR on f-string SQL -------------------------------------------- ``update_oauth_session`` builds its SET clause via ``f"{', '.join(update_fields)}"``; ``get_audit_logs`` builds its WHERE clause via string concatenation. Both are safe (the fragments only come from this function's own branches, no user input), but the patterns trip taint analysers. Annotated both with bare NOSONAR + safety comment explaining the hardcoded-fragments invariant. Note: S2077 doesn't currently fire on these; defensive. Bot 🟡#3 — pg_advisory_lock for concurrent migrations ----------------------------------------------------- Without coordination, two pods rolling-updating simultaneously can both observe ``has_alembic=False`` and both try to apply migrations from scratch — the second crashes with "relation already exists". New ``_migration_lock()`` async context manager: - On Postgres: ``SELECT pg_advisory_lock(:lock_id)`` on a fresh connection (separate from the engine pool so it survives the ``to_thread.run_sync`` worker), held across BOTH the schema-inspect AND the migration call. Without that span, two pods could each observe "no alembic_version" before either started migrating, defeating the lock. - On SQLite: yields immediately (file-level locking serializes writes natively). Lock ID derived from ``sha256(b"nextcloud-mcp-server:migrations")[:8]`` as a stable signed int64 so we can't collide with other apps sharing the same Postgres. Bot 🟡#4 — RefreshTokenStorage.close() + lifespan wiring -------------------------------------------------------- New idempotent ``close()`` method calls ``await engine.dispose()``, nulls the engine, resets ``_initialized``. Wired into both ``app_lifespan_basic`` (BasicAuth) and the OAuth lifespan teardown, each wrapped in ``try/except Exception`` with ``logger.warning`` so a buggy dispose can't block SIGTERM. Without this, pooled asyncpg connections leak server-side slots until ``idle_in_transaction_session_timeout`` reaps them — with small pool defaults and frequent k8s rolling restarts this can starve ``max_connections``. Bot 🟢#5 — is_sqlite_url docstring on :memory: ---------------------------------------------- Updated docstring to note both file-backed and in-memory forms are recognized; caller is responsible for ``:memory:`` magic. Bot 🟢#6 — db_path via make_url(...).database --------------------------------------------- Replaced ``database_url.split("///", 1)[1]`` hack with SQLAlchemy's own URL parsing. Naturally handles in-memory (``.database is None`` → falls back to ``""``). Same lazy-import pattern as the existing ``mask_db_password`` to avoid module-import-time cost. Bot 🟢#7 — _to_sync_url unrecognized-driver guard ------------------------------------------------- Pulled ``_KNOWN_ASYNC_DRIVERS = ("aiosqlite", "asyncpg")`` into a module constant. When an unrecognized ``+<driver>`` token survives the strip, emits ``logger.warning`` with the known-supported list. Behavior unchanged for valid URLs. Bot 🟢#8 — get_audit_logs SELECT * → explicit columns ----------------------------------------------------- Replaced ``SELECT *`` with explicit column list. Future schema additions stay out of the dict return. New tests --------- - ``test_close_disposes_engine``: pins the public contract — engine nulled, state reset, second call is a no-op. - ``test_concurrent_initialize_serialized_by_advisory_lock``: spawns 3 concurrent inits against a fresh schema; asserts no "relation already exists" and exactly one ``alembic_version`` row at the end. Without the lock, this reliably fails on the second concurrent task. Docs ---- - ADR-026: new "Concurrent migrations across pods" subsection documents the advisory-lock approach + lock-ID derivation. Verification ------------ - ``uv run pytest tests/unit/`` — 1025 passed. - ``TEST_DATABASE_URL=… uv run pytest tests/integration/test_storage_postgres.py -m postgres`` — 9 passed (was 7). - ``ruff check && ruff format --check && ty check`` — clean. Expected post-push: SQ scan reports 0 OPEN issues (was 4). 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>
2605 lines
92 KiB
Python
2605 lines
92 KiB
Python
"""
|
|
Persistent Storage for MCP Server State
|
|
|
|
This module provides SQL-backed storage for multiple concerns across both
|
|
BasicAuth and OAuth authentication modes. The default backend is SQLite
|
|
(file-based or per-process tempfile); set ``DATABASE_URL`` to a
|
|
``postgresql+asyncpg://...`` URL for HA k8s deployments where pods need
|
|
to be stateless. See :doc:`ADR-026 </docs/ADR-026-pluggable-database-backend>`
|
|
for the design.
|
|
|
|
Concerns covered:
|
|
|
|
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 hashlib
|
|
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__)
|
|
|
|
|
|
# Stable 64-bit signed integer used for the Postgres advisory-lock that
|
|
# serializes concurrent Alembic migrations across pods (ADR-026 →
|
|
# "Concurrent migrations"). Derived from a SHA-256 of a project-scoped
|
|
# string so we can't collide with other apps sharing the same DB.
|
|
_MIGRATION_LOCK_ID = int.from_bytes(
|
|
hashlib.sha256(b"nextcloud-mcp-server:migrations").digest()[:8],
|
|
"big",
|
|
signed=True,
|
|
)
|
|
|
|
|
|
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
|
|
# ``row._mapping`` is the documented public RowMapping accessor in
|
|
# SQLAlchemy 2.x (the leading underscore is historical); it returns
|
|
# a column-name → value mapping that survives the row being
|
|
# tuple-iterated. See SQLAlchemy 2.x ``Row.mapping`` docs.
|
|
return _Row(tuple(row), dict(row._mapping))
|
|
|
|
|
|
def _describe_ssl_arg(ssl_arg: object) -> str:
|
|
"""Render the ``ssl`` value for the startup log line.
|
|
|
|
Split out of the engine factory to avoid a nested-ternary
|
|
SonarQube finding (``S3358``) and to make the cases readable.
|
|
"""
|
|
if ssl_arg is False:
|
|
return "disabled"
|
|
if isinstance(ssl_arg, bool):
|
|
return "verify-full (system CAs)"
|
|
return "custom CA bundle"
|
|
|
|
|
|
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())
|
|
|
|
# Python's async-context-manager protocol *requires* ``__aenter__`` and
|
|
# ``__aexit__`` to be coroutines even when the body has nothing to
|
|
# await; dropping ``async`` would break ``async with _Cursor(...)``.
|
|
# The bare ``# NOSONAR`` markers below silence ``python:S7503``
|
|
# ("async function with no await") for that protocol-mandated reason.
|
|
async def __aenter__(self) -> "_Cursor": # NOSONAR
|
|
return self
|
|
|
|
async def __aexit__(self, *exc: object) -> None: # NOSONAR
|
|
# 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. We delegate
|
|
# the parsing to SQLAlchemy's ``make_url`` rather than splitting
|
|
# on ``///`` — same result for both 3-slash (relative) and
|
|
# 4-slash (absolute) SQLite URLs, plus correct handling of the
|
|
# in-memory ``:memory:`` form (``.database`` is ``None`` there).
|
|
if is_sqlite_url(database_url):
|
|
from sqlalchemy.engine.url import make_url # noqa: PLC0415
|
|
|
|
self.db_path = make_url(database_url).database or ""
|
|
else:
|
|
self.db_path = ""
|
|
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 a small bounded pool — see
|
|
# ``_build_postgres_engine`` for sizing rationale.
|
|
if is_sqlite:
|
|
self.engine = create_async_engine(
|
|
self.database_url,
|
|
poolclass=NullPool,
|
|
connect_args={"check_same_thread": False},
|
|
future=True,
|
|
)
|
|
else:
|
|
self.engine = self._build_postgres_engine()
|
|
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)
|
|
|
|
# Hold the advisory lock across BOTH the inspect and the migration
|
|
# call so two pods racing the rolling-update can't both see "no
|
|
# alembic_version" and both try to run from scratch. The lock is a
|
|
# no-op on SQLite (file-level locking serializes writes natively).
|
|
async with self._migration_lock():
|
|
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),
|
|
)
|
|
|
|
def _build_postgres_engine(self) -> AsyncEngine:
|
|
"""Construct the AsyncEngine for a Postgres ``DATABASE_URL``.
|
|
|
|
Split out from :meth:`initialize` so cognitive complexity stays
|
|
under the SonarQube ``S3776`` threshold and so a future
|
|
engine-arg unit test has a single seam to mock.
|
|
|
|
Defaults to ``pool_size=2, max_overflow=5`` (max 7 connections
|
|
per pod) — see ADR-026 § "Concurrency model and pool sizing"
|
|
for the rationale. asyncpg connections are single-flight, so
|
|
the pool only needs to cover the typical multi-user MCP burst,
|
|
not every potential in-flight tool call.
|
|
"""
|
|
# asyncpg ships as an optional PyPI extra (`[postgres]`) so the
|
|
# default `pip install nextcloud-mcp-server` audience doesn't
|
|
# pull in the 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.
|
|
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", _describe_ssl_arg(ssl_arg))
|
|
|
|
settings = get_settings()
|
|
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,
|
|
)
|
|
# Log the configured sizing so operators can spot
|
|
# over-allocation at startup without grepping config.
|
|
logger.info(
|
|
"Postgres engine ready: pool_size=%d max_overflow=%d "
|
|
"(per-pod max %d connections)",
|
|
settings.database_pool_size,
|
|
settings.database_max_overflow,
|
|
settings.database_pool_size + settings.database_max_overflow,
|
|
)
|
|
return engine
|
|
|
|
async def close(self) -> None:
|
|
"""Dispose the underlying AsyncEngine on shutdown.
|
|
|
|
Without an explicit dispose, asyncpg's pooled connections leak
|
|
server-side slots until the Postgres
|
|
``idle_in_transaction_session_timeout`` reaps them — with the
|
|
small pool defaults and frequent k8s rolling restarts this can
|
|
starve ``max_connections``. Idempotent: safe to call from any
|
|
number of shutdown hooks.
|
|
"""
|
|
if self.engine is None:
|
|
return
|
|
await self.engine.dispose()
|
|
self.engine = None
|
|
self._initialized = False
|
|
logger.info("Disposed token storage engine")
|
|
|
|
@asynccontextmanager
|
|
async def _migration_lock(self):
|
|
"""Serialize concurrent Alembic migrations across pods (ADR-026).
|
|
|
|
Without this, two pods rolling-updating at the same time can race
|
|
Alembic's version-table UPDATE and both try to apply migrations
|
|
from scratch — the second one crashes with "relation already
|
|
exists". On Postgres we acquire a session-level
|
|
:func:`pg_advisory_lock` so the second pod blocks until the
|
|
first finishes. SQLite serializes writes via its own file lock
|
|
and needs no extra coordination, so this is a no-op there.
|
|
|
|
The lock is held on a separate connection from the engine pool
|
|
so it survives the worker-thread ``to_thread.run_sync`` call
|
|
that actually runs Alembic.
|
|
"""
|
|
assert self.engine is not None, "engine must be built before migration lock"
|
|
if is_sqlite_url(self.database_url):
|
|
yield
|
|
return
|
|
|
|
async with self.engine.connect() as conn:
|
|
await conn.execute(
|
|
sa.text("SELECT pg_advisory_lock(:lock_id)"),
|
|
{"lock_id": _MIGRATION_LOCK_ID},
|
|
)
|
|
logger.debug(
|
|
"Acquired Postgres advisory migration lock %s", _MIGRATION_LOCK_ID
|
|
)
|
|
try:
|
|
yield
|
|
finally:
|
|
await conn.execute(
|
|
sa.text("SELECT pg_advisory_unlock(:lock_id)"),
|
|
{"lock_id": _MIGRATION_LOCK_ID},
|
|
)
|
|
logger.debug(
|
|
"Released Postgres advisory migration lock %s",
|
|
_MIGRATION_LOCK_ID,
|
|
)
|
|
|
|
@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()
|
|
|
|
# Explicit column list (not ``SELECT *``) so future audit_logs
|
|
# schema additions don't silently leak into the dict return.
|
|
query = (
|
|
"SELECT id, timestamp, event, user_id, resource_type, "
|
|
"resource_id, auth_method, hostname 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:
|
|
# ``query`` is built via string concatenation, but the fragments
|
|
# come only from this function's branches above (no
|
|
# user-controlled SQL); user input flows through ``params``.
|
|
# Bare ``# NOSONAR`` silences taint analysers; defensive.
|
|
async with db.execute(query, params) as cursor: # NOSONAR
|
|
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:
|
|
# ``update_fields`` only ever contains hardcoded ``"col = ?"``
|
|
# literals from this function's branches above — there is no
|
|
# user-controlled input in the SQL string itself, only in the
|
|
# ``params`` bound below. Bare ``# NOSONAR`` silences taint
|
|
# analysers that flag f-string SQL construction (e.g.
|
|
# ``python:S2077``); no such rule fires today, defensive.
|
|
cursor = await db.execute(
|
|
f"""
|
|
UPDATE oauth_sessions
|
|
SET {", ".join(update_fields)}
|
|
WHERE session_id = ?
|
|
""", # NOSONAR
|
|
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)
|