Commit Graph
29 Commits
Author SHA1 Message Date
Chris CoutinhoandClaude Opus 4.7 e2955e8246 fix(auth): address PR #758 round-5 medium/low review
Three findings from the latest review on #758 (1 medium, 2 low):

Medium:
- browser_oauth_routes.oauth_logout: move delete_browser_session into a
  finally block so an error from delete_refresh_token can no longer leave
  an orphan browser_sessions row. The orphan was not exploitable
  (SessionAuthBackend rejects sessions without a live refresh token), but
  it lingered until the hourly cleanup cron — a correctness gap. New
  regression test pins the fix.

Low:
- oauth_callback_nextcloud: drop redundant ``or None`` from
  ``expected_nonce=nonce``. ``nonce`` is already ``str | None`` and
  ``secrets.token_urlsafe`` never produces an empty string, so the
  coercion was a no-op that could mislead future readers into thinking
  empty-string was a valid skip-the-check path.
- storage.RefreshTokenStorage.initialize: fail fast at startup when
  SQLite < 3.35, since ``DELETE ... RETURNING`` (used in
  ``delete_browser_session``) needs that minimum. Ubuntu 20.04 ships
  3.31 and would otherwise hit OperationalError on every logout.
  Prerequisite also documented in docs/installation.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:37:38 +02:00
Chris CoutinhoandClaude Opus 4.7 b696541918 fix(auth): address PR #758 round-4 review
Seven findings from the latest review on #758 (3 medium, 4 low/nit):

Medium:
- storage.py: replace 5 ``assert self.cipher is not None`` sites with
  explicit ``RuntimeError`` so missing TOKEN_ENCRYPTION_KEY can't silently
  become an AttributeError under ``python -O``
- session_backend.py: document the silent-invalidation invariant —
  refresh-token TTL expiry without explicit logout deliberately makes
  the browser session unusable; future readers must not relax it
- server/oauth_tools.py: drop user_id from the Flow 2 session_id
  identifier — use ``flow2_{secrets.token_hex(16)}`` so audit logs and
  DB rows don't carry user_id in the session_id field

Low / nit:
- token_utils.py: drop _fetch_locks dict entry in finally so a probed
  deployment can't grow the lock dict without bound; coalescing test
  now pins the invariant with len(_fetch_locks) == 0
- browser_oauth_routes.py: strip trailing slash from settings.nextcloud_host
  before constructing the well-known URL so a host configured as
  ``https://cloud.example.com/`` doesn't produce a double-slash
- browser_oauth_routes.py: add comment explaining the three-layer CSRF
  policy on the mcp_session cookie set (SameSite=Lax + POST-only logout
  + Origin/Referer check)
- oauth_routes.py: convert all 23 f-string log calls to lazy %-style
  per the CLAUDE.md / memory feedback_lazy_logging convention

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:57:12 +02:00
Chris CoutinhoandClaude Opus 4.7 3a4fa8adc8 fix(auth): address PR #758 round-3 final review
Seven findings from the latest review on #758, plus a regression test
catching the substance of the cache-stampede fix:

- verify_id_token: widen id_token annotation to str | None to match
  callers passing nc_token_response.get("id_token")
- extract_user_id_from_token: use JSON-RPC reserved error code -32001
  instead of -1
- _get_cached: per-URL anyio.Lock dict + meta-lock coalesces concurrent
  cache misses into a single IdP fetch (mirrors token_broker.py idiom)
- delete_browser_session: collapse SELECT+DELETE into atomic
  DELETE ... RETURNING user_id (SQLite >= 3.35)
- new test_origin_normalise.py: parametrized port/scheme/host equivalence
  cases for the CSRF Origin guard
- browser_oauth_routes: correct misleading "PR #758 finding 5" cross-
  references (finding 5 was Fernet-key hardening, not CSRF)
- ASProxySession.nonce: make required, drop spurious "legacy session"
  default; reword the in-flight `or None` comment to reflect that
  ASProxySession is purely in-memory
- new test_get_cached_coalesces_concurrent_misses: pins the
  cache-stampede protection — fires 10 concurrent _get_cached calls and
  asserts exactly one HTTP fetch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:14:07 +02:00
Chris CoutinhoandClaude Opus 4.7 9d0e7dcebe fix(auth): address PR #758 round-3 review
- Flow 2 (oauth_authorize_nextcloud) now generates a nonce, stores it on
  the oauth_session row, forwards it to the IdP, and verifies it via
  expected_nonce in oauth_callback_nextcloud — closes the last replay-
  protection gap (round-3 finding 1).
- _origin_matches_self fails closed when mcp_server_url is missing
  instead of allowing the logout, and the diagnostic log is promoted
  from warning to error so the misconfiguration is monitorable
  (round-3 finding 2). New regression test pins the new behaviour.
- The five user_id-accepting helpers in oauth_tools.py (get_provisioning_status,
  provision_nextcloud_access, revoke_nextcloud_access, check_provisioning_status,
  check_logged_in) are renamed with leading underscores to make the
  trust boundary structural rather than documentary
  (round-3 finding 3).
- create_browser_session and delete_browser_session now emit audit_log
  rows so session establishment / teardown match the pattern used by
  the rest of the security-relevant storage operations
  (round-3 nit 5). delete_browser_session selects user_id before delete
  so the audit row is attributable.
- oauth_login_callback no longer reflects raw IdP-error text or
  exception strings into the HTML failure page; users see a generic
  "internal error occurred" message + a correlation ID, with the
  detail logged server-side keyed by the same ID (round-3 nit 6).
  The XSS regression test is updated to pin the stricter contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:40:06 +02:00
Chris CoutinhoandClaude Opus 4.7 c33d52ea91 fix(auth): address PR #758 round-2 review
- oauth_login_callback's integrated-mode token-exchange branch now reuses
  the shared discovery cache via get_oidc_discovery (round-2 finding 1).
- AS proxy flow now generates an OIDC nonce in oauth_authorize, stores it
  on ASProxySession, forwards it to the IdP, and passes it as
  expected_nonce to verify_id_token in _oauth_callback_as_proxy
  (round-2 finding 2).
- Consolidate the two parallel discovery caches: oauth_routes' local
  _discovery_cache and _get_cached_discovery are removed; all callers
  now go through token_utils.get_oidc_discovery, which acquires the
  follow_redirects=True knob it needs for Nextcloud installs without
  pretty URLs (round-2 finding 3).
- Demote per-user INFO logs in oauth_tools.py (check_logged_in,
  get_provisioning_status) to DEBUG; the elicitation auth URL is no
  longer logged because it contains a sensitive state token
  (round-2 finding 4).

Also pin nonce binding behaviour with a new unit test that asserts
_oauth_callback_as_proxy forwards session.nonce to verify_id_token, and
update test mocks to track the cache consolidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:56:08 +02:00
Chris CoutinhoandClaude Opus 4.7 4c84d82984 fix(auth): address PR #758 auto-review (id-token verify, nonce, CI key)
Blocking:
- AS proxy callback now calls verify_id_token before caching the proxy
  code so a tampered IdP response can't smuggle identity claims.

Important:
- Browser OAuth flow generates and verifies an OIDC nonce; new alembic
  migration 006 adds the nonce column to oauth_sessions.
- _origin_matches_self logs a warning when CSRF check is bypassed.
- oauth_tools.py uses get_shared_storage instead of fresh handles.

Nits:
- New token_utils.get_oidc_discovery shares the 5-minute cache with
  verify_id_token; oauth_login (integrated) and _revoke_refresh_token_at_idp
  now use it instead of issuing fresh discovery fetches.
- Drop typing.Optional from oauth_tools.py in favour of X | None.

CI:
- test.yml generates an ephemeral Fernet TOKEN_ENCRYPTION_KEY per run
  with openssl, removing the dependency on a missing repo secret.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:48:25 +02:00
Chris CoutinhoandClaude Opus 4.7 2d340a5a6b fix(auth): address PR #758 follow-up review
Six findings from the latest claude-bot review on PR #758:

- JWKS cache had no kid-miss refresh path (Medium): on IdP key
  rotation every login failed for up to _OIDC_CACHE_TTL. Evict
  and refetch once before raising, per OIDC core §10.1.1.
- _should_use_secure_cookies fell back to nextcloud_host scheme,
  but the cookie is issued by the MCP server. Switch to
  settings.nextcloud_mcp_server_url so split-scheme deployments
  get the right Secure flag.
- _origin_matches_self compared raw netloc strings, which include
  the port. Browsers omit default ports per RFC 6454 §6.2; an
  mcp_server_url like :443 falsely 403'd every legitimate logout.
  Normalise (scheme, host, port) tuples with default ports stripped.
- delete_oauth_session exists in storage.py — drop the stale
  "we don't have this method" comment and call it eagerly so
  replays can't be processed and the table doesn't accumulate
  completed-but-not-yet-expired browser-login rows.
- extract_user_id_from_token's unused ctx param renamed to _ctx
  to signal "intentionally unused" at the signature level.
- provisioning_decorator instantiated RefreshTokenStorage per
  call. Switch to get_shared_storage() for the lock-protected
  process-wide singleton.

Plus pre-push self-review catch: lazy-logging on the unchanged
except arm in session_backend.py.

Adds 5 regression tests:
  - JWKS rotation: success on refetch
  - JWKS rotation: still-missing-kid surfaces original error
  - JWKS rotation: network error during refresh wrapped as
    IdTokenVerificationError
  - default-port CSRF: explicit :443 in config + portless Origin
  - default-port CSRF: portless config + explicit :443 in Origin
  - scheme-mismatch CSRF: same host, different scheme rejected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:59:34 +02:00
Chris CoutinhoandClaude Opus 4.7 af25c281bf fix(auth): use Settings for OIDC env vars in token revocation helper
After merging master, _should_use_secure_cookies was refactored to read
from Settings instead of os.getenv, which dropped `import os` from
browser_oauth_routes.py — leaving _revoke_refresh_token_at_idp's four
remaining os.getenv() calls undefined (CI ruff F821).

Migrate the helper to the same Settings-based pattern:
  - oidc_discovery_url     → settings.oidc_discovery_url
  - OIDC_CLIENT_ID         → settings.oidc_client_id
  - OIDC_CLIENT_SECRET     → settings.oidc_client_secret
  - NEXTCLOUD_HOST         → settings.nextcloud_host

Drive-by: the previous fallback read OIDC_CLIENT_ID, but the canonical
env var per env.sample / docker-compose is NEXTCLOUD_OIDC_CLIENT_ID.
The Settings layer handles this mapping via dynaconf, so the corrected
name is now used automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:32:19 +02:00
Chris Coutinho 50a97ffcb3 Merge remote-tracking branch 'origin/master' into security/oauth-session-hardening-626 2026-05-02 18:29:53 +02:00
Chris CoutinhoandClaude Opus 4.7 931ee602eb fix(auth): address PR #758 review — XSS, CSRF, open redirect, JWKS cache
Addresses all 9 findings from the review on PR #758:

Blocking:
- _revoke_refresh_token_at_idp now reads config from oauth_ctx["config"]
  (the production-shaped nested dict). Previously read flat keys, causing
  IdP revocation to silently no-op in production. Test fixtures rebuilt
  to the realistic nested shape so the bug can't regress unnoticed.
- HTML error responses in oauth_login_callback now wrap IdP-controlled
  error_body, str(e), and the attacker-controlled error/error_description
  query params in html_escape. New test_browser_oauth_xss.py pins this.

Important:
- New _safe_next_url helper validates the ?next= query param at write
  time (oauth_login), in oauth_logout, and on read from the session row
  in oauth_login_callback. Blocks https://, // (protocol-relative), and
  CRLF/whitespace injection.
- verify_id_token now caches discovery + JWKS (5-min TTL) using the
  same pattern as oauth_routes._get_cached_discovery. New caching
  regression test pins to one fetch per URL across multiple calls.
- /oauth/logout is now POST-only at the route layer (defeats passive
  CSRF via <img src>). oauth_logout also validates Origin/Referer
  against the configured mcp_server_url. Logout UI in user_info.html
  converted from <a href> to <form method="post">.
- New storage.cleanup_expired_browser_sessions() called from the hourly
  cleanup loop in app.py — previously these rows accumulated for users
  who never explicitly logged out.

Nits:
- Demoted INFO logs that leaked oauth_config.keys() / client_id /
  token-storage state to DEBUG. Operator-relevant outcome lines
  (login successful, refresh token stored, logged out) stay INFO.
- verify_id_token algorithms widened to RS256, PS256, ES256 — covers
  Azure AD (PS256) and Cognito/some Keycloak realms (ES256). Symmetric
  and "none" remain off the allowlist.
- Migrated all Optional[X] usages in auth/storage.py to X | None per
  CLAUDE.md.

Breaking change: GET /oauth/logout now returns 405. The in-tree logout
UI was migrated to a POST form; any external bookmark or curl-based
caller that relied on GET will need to switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:26:39 +02:00
Chris CoutinhoandClaude Opus 4.7 ce80a36877 fix(auth): address PR #757 round-3 review feedback
Three review items from the third-round review on PR #757:

- scope_authorization: split the combined logger.warning(error_msg) in
  the require_scopes decorator's missing-app-password branch into two
  lazy %-style logger calls (one per branch), keeping the f-string
  error_msg for the exception only. The else branch also logs the
  elicit_result for diagnostics. Bypassing lazy %-interpolation in
  security-sensitive code formatted the message regardless of log level
  and matched the repo-wide lazy-logging preference; the new code now
  conforms.
- config + browser_oauth_routes: wire COOKIE_SECURE through Settings
  (cookie_secure: bool | None = None) so _should_use_secure_cookies()
  reads it via get_settings() rather than os.getenv. Completes the
  consolidation pass that touched this file in commit 7464340 and
  removes the last raw os.getenv from browser_oauth_routes.py
  (import os dropped). Dynaconf auto-coerces "true"/"false" → bool;
  "1"/"0" arrive as int and are normalised by an explicit bool() at
  the consumer.
- elicitation: clarify the _astrolabe_settings_url docstring to call
  out that the empty-string case is also a None-return path (matches
  the existing `if not base:` guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:24:50 +02:00
Chris CoutinhoandClaude Opus 4.7 15dbb26349 fix(auth): harden OAuth/session for hosted multi-tenant deployment (#626)
Pre-launch hardening for the hosted Astrolabe Cloud offering. Addresses
all five findings raised in #626 (Tim Kaufmann, code review of v0.65.0).
Re-verified against master before fixing.

Finding 3 (LLM-controllable user_id) — drop user_id from the public
signatures of provision_nextcloud_access, revoke_nextcloud_access,
check_provisioning_status, check_logged_in. Tool wrappers now always
derive identity from the verified AccessToken; user_id is no longer
accepted as MCP input. Adds parameterized CI-guard test that locks the
schema.

Finding 2 (predictable session cookie) — replace mcp_session=<user_id>
cookie with a cryptographically random session_id mapped server-side
(new browser_sessions table, alembic 005). Cookie value is opaque,
expires, revocable. SessionAuthBackend looks up user_id via the new
mapping and additionally requires a refresh token to fail closed.

Finding 4 (logout doesn't revoke refresh token) — oauth_logout now
calls the IdP revocation_endpoint (RFC 7009) when advertised, deletes
the stored refresh token regardless, and clears the browser_sessions
row. Cleanup is best-effort: logout always 302s.

Finding 1 (unverified ID token decodes) — verify_id_token helper does
JWKS signature + issuer + audience + exp + nonce checks per OIDC core
3.1.3.7. Used by both OAuth callback handlers (browser + MCP). Removes
the four "verify_signature: False" decodes that previously trusted IdP
claims unconditionally. Drops dead-code _validate_token_audience in
token_broker. Refactors token_utils + provisioning_decorator to read
user_id from the verified AccessToken instead of re-decoding the JWT.

Finding 5 (hardcoded Fernet keys in docker-compose.yml) — replace the
three inline TOKEN_ENCRYPTION_KEY values with required env var
interpolation; document in env.sample.

Test coverage: 4 new unit test modules (signature pinning, browser
sessions, ID-token verification, logout + revoke + session backend).
693 unit tests pass; ruff/format/ty clean.

Migration note: existing browser admin-UI sessions become invalid on
rollout (cookies are looked up against the new browser_sessions table,
which starts empty). Users re-login. MCP API access is unaffected.

Tracked on Astrolabe Cloud POC board card #37.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:03:57 +02:00
Chris CoutinhoandClaude Opus 4.7 7464340763 fix(auth): address PR #757 round-2 review feedback
Four review items from the second-round review on PR #757:

- scope_authorization: broaden the post-elicit retry message to acknowledge
  the 5-minute scope-cache TTL — if the LFv2 poller is still in-flight at
  acknowledge-time, the immediate retry can still hit a stale cache.
- elicitation: extract a shared `_run_elicit(ctx, message, schema, *,
  log_label)` helper so `present_login_url` and
  `present_provisioning_required` no longer duplicate the
  hasattr-guard / try-NotImplementedError / try-Exception fallback block.
  The data-acknowledged warning specific to login-flow stays in
  `present_login_url` so behaviour is preserved exactly.
- elicitation: detect missing http:// / https:// scheme in
  `_astrolabe_settings_url`, log a warning, and return None — caller
  renders the safe tool-only fallback instead of producing a broken link.
  New unit test locks this in.
- browser_oauth_routes: replace the stray
  `os.getenv(\"NEXTCLOUD_HOST\")` in `_should_use_secure_cookies` with
  `get_settings().nextcloud_host` for consistency with the rest of the
  file (PR #757 review nit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:33:43 +02:00
Chris CoutinhoandClaude Opus 4.7 f3256e515e refactor(config): consolidate NEXTCLOUD_PUBLIC_ISSUER_URL through Settings
Lift NEXTCLOUD_PUBLIC_ISSUER_URL out of raw os.getenv reads into
Settings.nextcloud_public_issuer_url across all 8 production call sites
(app.py x2, oauth_routes.py x2, browser_oauth_routes.py,
provision_routes.py, userinfo_routes.py, elicitation.py). cli.py
remains the env-write source so the existing config-by-flag pipeline
still works.

Also addresses remaining PR #757 review nits:
- elicitation.py: align URL-present/absent wording on "open in your
  browser" so users don't try clicking in the terminal
- test_scope_authorization_stored.py: lock in the deliberately-shared
  fall-through branch with explicit declined/cancelled decorator tests
- test_elicitation.py: switch from monkeypatch.setenv to
  patch(get_settings) since Settings is now the canonical surface

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:16:19 +02:00
Chris CoutinhoandClaude Opus 4.6 7730f926cb fix: conditionally include offline_access based on IdP discovery
AWS Cognito provides refresh tokens automatically with the authorization
code flow but does not list offline_access as a supported scope. Check
the IdP's scopes_supported discovery field before including it in
requests, and always accept refresh tokens from responses regardless.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:48:49 +02:00
Chris CoutinhoandClaude Opus 4.6 29fd0486c9 refactor: change OAuth scope separator from colon to dot for IDP compatibility
Many identity providers (AWS Cognito, Okta, Azure AD) reject or mishandle
colons in OAuth scope names. This migrates all custom scopes from
`resource:action` to `resource.action` format (e.g., `notes:read` →
`notes.read`), which is universally accepted and aligns with industry
conventions (Microsoft, Google).

Includes Alembic migration 004 for stored scope strings and ADR-024
documenting the rationale and RFC references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:07:02 +02:00
Chris CoutinhoandClaude Opus 4.6 a11ae9c027 refactor: enforce PLC0415 (import-outside-top-level) for source code
Enable ruff PLC0415 rule for all source files (tests excluded via
per-file-ignores). Move 136 inline imports to top-level across 33 files.
8 imports suppressed with noqa for legitimate reasons: circular
dependencies (client/__init__.py, context.py), optional dependency
guards (app.py document processors, auth/userinfo_routes.py), and
post-env-setup imports (smithery_main.py).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:04:50 +01:00
Chris CoutinhoandClaude Opus 4.6 1707b2e6e1 feat: add self-signed SSL certificate support for Nextcloud connections
Add NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE env vars to configure
TLS certificate verification for all outbound Nextcloud connections.
Centralizes SSL config via a new HTTP client factory (http.py) used by
all 27 Nextcloud-bound call sites, including API clients, OIDC endpoints,
OAuth flows, and health checks.

Closes #560

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:21:21 +01:00
Chris Coutinho 056414752e fix(mcp): Move all imports to the top of modules 2025-12-26 10:05:27 -06:00
Chris Coutinho 5e76ddc60d feat: Remove URL rewriting in favor of proper nextcloud config
Remove URL rewriting logic from MCP server that was converting
      public URLs to internal Docker URLs. This was a workaround for
      Nextcloud's overwritehost setting forcing URLs to localhost:8080.

      Changes:
      - Remove OIDC endpoint rewriting in app.py (setup_oauth_config)
      - Remove OIDC_JWKS_URI override support (no longer needed)
      - Remove URL rewriting in browser_oauth_routes.py
      - Remove URL rewriting in token_broker.py
      - Update Helm chart values and README
      - Add hybrid auth setup unit tests
      - Update Astrolabe admin UI for Vue 3

      The proper fix is in the previous commit which removes the
      overwritehost setting from Nextcloud, allowing it to respect
      the Host header from incoming requests.
2025-12-23 11:34:57 -07:00
Chris CoutinhoandClaude Sonnet 4.5 daabd90359 fix(security): address critical security issues from PR #401 code review
Implemented 6 critical security fixes identified during PR #401 review:

1. Token Rotation Race Condition (Issue 1)
   - Added in-progress marker pattern to prevent concurrent refresh
   - Prevents token invalidation when multiple requests refresh simultaneously
   - File: token_broker.py:324, 343-390

2. Hardcoded Localhost URL (Issue 2)
   - Added getNextcloudBaseUrl() with fallback chain
   - Supports overwrite.cli.url, trusted_domains, and localhost fallback
   - File: IdpTokenRefresher.php:38-61, 116

3. Error Information Leakage (Issue 3)
   - Replaced 13 instances of str(e) with sanitized errors
   - Prevents exposure of stack traces, paths, and tokens
   - File: management.py:368, 444, 492, 510, 546, 571, 625, 643, 695, 750, 919, 956, 1121

4. Input Validation Gaps (Issue 4)
   - Added validation helpers: _parse_int_param, _parse_float_param, _validate_query_string
   - Applied bounds checking to get_chunk_context and unified_search
   - File: management.py:119-164, 807-835, 1197-1212

5. PHP Refresh Token Validation (Issue 5)
   - Added explicit refresh_token presence check
   - Prevents silent token rotation failures
   - File: IdpTokenRefresher.php:122-132

6. Cookie Security Configuration (Issue 6)
   - Added _should_use_secure_cookies() with auto-detection
   - Supports explicit COOKIE_SECURE env var or auto-detect from NEXTCLOUD_HOST
   - Files: browser_oauth_routes.py:27-44, 470; env.sample:54-57

Testing:
- Unit tests: 195 passed
- Integration tests: 102 passed, 4 skipped
- OAuth tests: 9 passed
- All linting and type checks passed

Follow-up work tracked in issues #408-#417

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-19 13:57:33 +01:00
Chris CoutinhoandClaude Sonnet 4.5 3fa376905c feat: add Alembic database migration system
Implements Alembic for managing token storage database schema versions.
Migrations run automatically on startup with full backward compatibility.

**Changes:**
- Add Alembic dependency (1.14.0+) and SQLAlchemy (auto-installed)
- Create migration infrastructure in alembic/ directory
- Add initial migration (001) capturing current schema
- Modify RefreshTokenStorage.initialize() to run migrations via anyio
- Add CLI commands: db upgrade, current, history, downgrade, migrate
- Add comprehensive migration documentation

**Backward Compatibility:**
- Pre-Alembic databases automatically stamped with revision 001
- No schema changes for existing databases
- Automatic upgrade on first startup after update

**Migration Strategy:**
Three scenarios handled:
1. New database → Run migrations from scratch
2. Pre-Alembic database → Stamp with 001 (no changes)
3. Alembic-managed → Upgrade to latest

**Architecture:**
- Uses anyio.to_thread.run_sync() for structured concurrency
- Alembic env.py runs with anyio.run() in worker thread
- SQLite-friendly migration patterns documented
- No ThreadPoolExecutor needed (anyio handles it)

**CLI Usage:**
```bash
nextcloud-mcp-server db upgrade    # Upgrade to latest
nextcloud-mcp-server db current    # Show version
nextcloud-mcp-server db history    # View changelog
nextcloud-mcp-server db downgrade  # Rollback (with confirmation)
nextcloud-mcp-server db migrate "description"  # Create migration
```

**Testing:**
- All 13 webhook storage tests pass
- New/pre-Alembic database scenarios validated
- anyio integration tested

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:02:09 +01:00
Chris CoutinhoandClaude Opus 4.5 a58a14111b feat(vector-sync): enable background sync in OAuth mode
Add multi-user background vector synchronization when running in OAuth
mode with ENABLE_OFFLINE_ACCESS=true. Key changes:

Architecture (oauth_sync.py):
- User Manager task polls RefreshTokenStorage for provisioned users
- Per-user scanner tasks fetch documents using OAuth tokens
- Shared processor pool indexes documents from all users

Token Broker improvements:
- Accept client_id/client_secret instead of encryption_key
- Remove redundant token audience pre-validation (Nextcloud validates)
- Add _rewrite_token_endpoint for Docker internal URL routing
- Remove double-decryption (storage handles encryption internally)

Browser OAuth flow fixes:
- Add 'resource' parameter to request Nextcloud-scoped tokens
- Store and retrieve next_url for proper redirect after consent
- Rewrite token endpoint URLs for internal Docker access

Configuration:
- Add vector_sync_user_poll_interval setting (default: 60s)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 20:00:41 +01:00
Chris CoutinhoandClaude d86a185e04 refactor: move webapp from /user/page to /app
Simplified the webapp routing structure by consolidating the admin UI
to a single clean endpoint.

Changes:
- Moved webapp from /user/page to /app (root of mount)
- Removed /user JSON endpoint (no longer needed)
- Updated mount point from /user to /app in app.py
- Updated all route path checks (3 locations)
- Updated OAuth redirects to point to /app
- Updated all HTMX endpoint references
- Updated documentation (ADR-007, CHANGELOG)
- Added redirect from /app to /app/ for trailing slash handling

New Route Structure:
- /app - Main webapp (HTML UI with tabs)
- /app/revoke - Revoke background access
- /app/webhooks - Webhook management UI
- /app/webhooks/enable/{preset_id} - Enable webhook preset
- /app/webhooks/disable/{preset_id} - Disable webhook preset

Breaking Change: Existing bookmarks to /user or /user/page will no longer work.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 20:53:43 +01:00
Chris CoutinhoandClaude 11cdab475f feat: unify session architecture and enhance login status visibility
This commit addresses the "Login not detected" issue after completing
OAuth login via elicitation by unifying the session architecture and
adding comprehensive visibility into background session status.

## Changes

### 1. Enhanced check_logged_in with comprehensive logging (oauth_tools.py)
- Added detailed logging at each step of token lookup
- Implemented fallback strategy: first search by provisioning_client_id,
  then fall back to user_id lookup
- This allows detection of refresh tokens created via any flow
  (elicitation or browser login)
- Log messages include flow_type, provisioned_at, and provisioning_client_id
  for debugging

### 2. Unified session architecture (browser_oauth_routes.py)
- Browser login now stores provisioning_client_id=state when saving
  refresh token
- This makes browser and elicitation flows consistent - both can be
  found by the same state parameter
- Treats Flow 2 (elicitation) and browser login as the same "background
  session"

### 3. Enhanced /user/page with session status (userinfo_routes.py)
- Added comprehensive background access section showing:
  - Background Access: Granted/Not Granted (with visual indicators)
  - Flow Type: browser/flow2/hybrid
  - Provisioned At: timestamp
  - Token Audience: nextcloud/mcp
  - Scopes: detailed scope list
- Status displayed regardless of which flow created the session
  (browser login or elicitation)

### 4. Added revoke functionality (userinfo_routes.py, app.py)
- New POST endpoint: /user/revoke
- Allows users to revoke background access (delete refresh token)
- Browser session cookie remains valid for UI access
- Confirmation dialog before revocation
- Success page with auto-redirect back to /user/page
- Registered route in app.py browser_routes

## Testing
All tests pass:
- 6/6 login elicitation tests pass
- 21/21 core OAuth tests pass
- Comprehensive logging helps debug future issues

## Fixes
Resolves: "Login not detected. Please ensure you completed the login
at the provided URL before clicking OK."

The issue occurred because elicitation and browser login created
separate sessions. Now they are unified under the same architecture.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 21:50:55 +01:00
Chris CoutinhoandClaude 0c9a9ea24d fix: Consolidate OAuth callbacks and implement PKCE for all flows
This PR fixes multiple OAuth-related issues:

## Unified OAuth Callback
- Consolidated `/oauth/callback-nextcloud` and `/oauth/login-callback` into single `/oauth/callback` endpoint
- Flow type determined by session lookup via state parameter (no query params in redirect_uri)
- Fixes redirect_uri validation issues with IdPs requiring exact match
- Legacy endpoints kept as aliases for backwards compatibility

## PKCE Implementation
- Implemented PKCE (RFC 7636) for Flow 2 (resource provisioning)
  - Generate code_verifier and code_challenge
  - Store code_verifier in session storage
  - Retrieve and use in token exchange
- Fixed PKCE for browser login (integrated mode)
  - Previously only worked for external IdP (Keycloak)
  - Now works for both Nextcloud OIDC and external IdP

## Login Elicitation Fixes (ADR-006)
- Fixed elicitation URL to route through MCP server endpoint
  - Changed from direct Nextcloud URL to `/oauth/authorize-nextcloud`
  - Ensures PKCE is properly handled by server
- Fixed login detection after OAuth flow completes
  - Look up refresh token by state parameter instead of user_id
  - Works even when Flow 1 token not present
- Added `get_refresh_token_by_provisioning_client_id()` method

## Session Authentication
- Fixed `/user/page` redirect loop
  - Shared oauth_context with mounted browser_app
  - SessionAuthBackend can now validate sessions correctly

## Tests
- Added comprehensive login elicitation test suite
- Updated scope authorization test expectations
- All 43 OAuth tests passing

## Files Changed
- `app.py`: Shared oauth_context, unified callback route
- `oauth_routes.py`: Unified callback, PKCE for Flow 2
- `browser_oauth_routes.py`: PKCE for integrated mode
- `oauth_tools.py`: Fixed elicitation URL generation
- `refresh_token_storage.py`: Added lookup by provisioning_client_id
- `test_login_elicitation.py`: New test suite

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 21:08:55 +01:00
Chris CoutinhoandClaude de99296779 feat: implement scope-based audience mapping and RFC 9728 support
This commit removes hardcoded Keycloak audience mappers and implements
dynamic audience assignment based on OAuth client scopes and RFC 8707
resource indicators.

## MCP Server Changes

### Protected Resource Metadata (app.py)
- Change resource field from client_id to URL (RFC 9728 compliance)
- Use `{mcp_server_url}/mcp` as resource identifier
- Update DCR registration to include all Nextcloud API scopes
- Add resource_url parameter to client registration

### Client Registration (auth/client_registration.py)
- Add resource_url parameter to register_client()
- Pass resource_url to DCR endpoint
- Support RFC 9728 resource metadata

### Browser OAuth Routes (auth/browser_oauth_routes.py)
- Enhanced error logging for token exchange failures
- Log HTTP status code and response body for debugging
- Improved error messages for OAuth provisioning issues

### Token Verifier (auth/progressive_token_verifier.py)
- Add introspection_uri and client_secret parameters
- Initialize HTTP client for introspection requests
- Enable opaque token validation support

## Keycloak Configuration

### realm-export.json
- **Remove** hardcoded `audience-mcp-server` protocol mapper
- Audience now determined by client scopes:
  - External clients: RFC 8707 resource parameter → `aud: {resource_url}`
  - MCP Server: `token-exchange-nextcloud` scope → `aud: "nextcloud"`

### OIDC App (third_party/oidc)
- Updated submodule with RFC 9728 support
- Added resource_url database field
- Enhanced introspection authorization logic

## Architecture

Two separate audience flows:

1. **Gemini CLI → MCP Server**
   - Client requests: `resource=http://localhost:8002/mcp`
   - Token audience: `aud: "http://localhost:8002/mcp"`
   - MCP server validates via progressive_token_verifier

2. **MCP Server → Nextcloud APIs**
   - MCP server includes: `scope=token-exchange-nextcloud`
   - Token audience: `aud: "nextcloud"` (via scope mapper)
   - Nextcloud user_oidc validates via SelfEncodedValidator

## Benefits
-  RFC 8707 compliant (resource indicators)
-  RFC 9728 compliant (protected resource metadata)
-  Dynamic audience based on OAuth context
-  Fixes Gemini CLI authentication failures
-  Maintains Nextcloud API access for background jobs
-  Clear security boundaries between flows

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 05:28:58 +01:00
Chris Coutinho d14f2f666d feat: Add userinfo route/page 2025-11-04 00:03:24 +01:00
Chris CoutinhoandClaude c2dcb06fe1 feat: add browser-based user info page with separate OAuth flow
Implements /user and /user/page endpoints for displaying authenticated
user information in both BasicAuth and OAuth modes.

Key Features:
- Separate browser OAuth flow (/oauth/login, /oauth/login-callback, /oauth/logout)
- Session-based authentication using signed cookies
- Token refresh for persistent sessions
- HTML and JSON user info endpoints
- IdP profile information retrieval

Architecture:
- BasicAuth mode: Always authenticated as configured user
- OAuth mode: Browser-based authorization code flow with refresh tokens
- Session stored in SQLite with encrypted refresh tokens
- Server-side token refresh using internal Docker hostnames

OAuth Flow:
- /oauth/login: Initiates browser OAuth flow
- /oauth/login-callback: Handles IdP callback and stores refresh token
- /oauth/logout: Clears session cookie
- /user: JSON API endpoint (requires authentication)
- /user/page: HTML page endpoint (requires authentication)

DCR Scopes Fix:
- MCP server DCR now only requests basic OIDC scopes (openid profile email offline_access)
- Nextcloud app scopes (notes:read, etc.) are for MCP clients, not the server itself
- PRM endpoint dynamically advertises supported scopes from tool decorators

Files:
- nextcloud_mcp_server/auth/browser_oauth_routes.py: Browser OAuth flow handlers
- nextcloud_mcp_server/auth/session_backend.py: Starlette session authentication
- nextcloud_mcp_server/auth/userinfo_routes.py: User info endpoints with token refresh
- tests/server/auth/test_userinfo_routes.py: Unit tests
- tests/server/oauth/test_userinfo_integration.py: OAuth integration tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 22:16:49 +01:00