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>
This commit is contained in:
Chris Coutinho
2026-05-02 17:03:57 +02:00
co-authored by Claude Opus 4.7
parent 83f2e88d2c
commit 15dbb26349
16 changed files with 1184 additions and 242 deletions
+133 -23
View File
@@ -10,14 +10,18 @@ import os
import secrets
import time
from base64 import urlsafe_b64encode
from html import escape as html_escape
from urllib.parse import urlencode
from urllib.parse import urlparse as parse_url
import httpx
import jwt
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
from nextcloud_mcp_server.auth.token_utils import (
IdTokenVerificationError,
verify_id_token,
)
from nextcloud_mcp_server.auth.userinfo_routes import (
_get_userinfo_endpoint,
_query_idp_userinfo,
@@ -383,16 +387,44 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
logger.info(f"Refresh token present: {refresh_token is not None}")
logger.info(f"ID token present: {id_token is not None}")
# Decode ID token to get user info
# Resolve the discovery URL + audience used for THIS auth request so
# we can verify the ID token signature + claims (issue #626 finding 1).
if oauth_client:
# External IdP path
verification_audience = oauth_client.client_id
verification_discovery_url = getattr(oauth_client, "discovery_url", None)
else:
# Integrated Nextcloud OIDC path
verification_audience = oauth_config["client_id"]
verification_discovery_url = oauth_config.get("discovery_url")
if not verification_discovery_url:
logger.error("Cannot verify ID token: no discovery_url available")
return HTMLResponse(
"<h1>Login Failed</h1><p>OIDC discovery URL not configured</p>",
status_code=500,
)
try:
userinfo = jwt.decode(id_token, options={"verify_signature": False})
user_id = userinfo.get("sub")
username = userinfo.get("preferred_username") or userinfo.get("email")
logger.info(f"Browser login successful: {username} (sub={user_id})")
except Exception as e:
logger.warning(f"Failed to decode ID token: {e}")
user_id = f"user-{secrets.token_hex(8)}"
username = "unknown"
userinfo = await verify_id_token(
id_token,
discovery_url=verification_discovery_url,
expected_audience=verification_audience,
)
except IdTokenVerificationError as e:
logger.error("ID token verification failed: %s", e)
# html_escape: defense-in-depth. The exception text is currently
# server-constructed, but escape on the success path too so any
# future error wrapping that includes IdP response text can't
# smuggle markup into the login-failure page.
return HTMLResponse(
f"<h1>Login Failed</h1><p>ID token failed verification: {html_escape(str(e))}</p>",
status_code=400,
)
user_id = userinfo["sub"]
username = userinfo.get("preferred_username") or userinfo.get("email")
logger.info("Browser login successful: %s (sub=%s)", username, user_id)
# Calculate refresh token expiration from token response
refresh_expires_in = token_data.get("refresh_expires_in")
@@ -455,40 +487,118 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
logger.error(f"Error caching user profile: {e}")
# Continue anyway - profile cache is optional for browser UI
# Create response and set session cookie
# Redirect to stored next_url (from OAuth session) or /app as default
# Create a server-side browser session: a random opaque session_id is
# mapped to the verified user_id in `browser_sessions`. The cookie value
# is the session_id (never the raw user_id — see issue #626 finding 2).
session_id = secrets.token_urlsafe(32)
session_ttl = 86400 * 30 # 30 days
await storage.create_browser_session(
session_id=session_id, user_id=user_id, ttl_seconds=session_ttl
)
response = RedirectResponse(next_url, status_code=302)
response.set_cookie(
key="mcp_session",
value=user_id,
max_age=86400 * 30, # 30 days
value=session_id,
max_age=session_ttl,
httponly=True,
secure=_should_use_secure_cookies(),
samesite="lax",
)
logger.info(f"Session cookie set for user: {username}")
logger.info("Session cookie set for user %s (sid=%s…)", username, session_id[:8])
return response
async def oauth_logout(request: Request) -> RedirectResponse:
"""Browser OAuth logout - clears session cookie.
"""Browser OAuth logout — invalidate session and revoke refresh token.
Issue #626 finding 4: prior implementation only cleared the cookie,
leaving the refresh token in storage (valid up to 90 days). This now:
1. Resolves the user_id for the current browser session_id.
2. Calls the IdP `revocation_endpoint` for the stored refresh token
when the IdP advertises one.
3. Deletes the stored refresh token regardless of revocation success.
4. Deletes the browser_sessions row so the cookie is unusable even
if it leaks.
5. Clears the cookie on the response.
Query parameters:
next: Optional URL to redirect to after logout (default: /oauth/login)
Returns:
302 redirect with cleared session cookie
"""
next_url = request.query_params.get("next", "/oauth/login")
session_id = request.cookies.get("mcp_session")
# TODO: Optionally revoke refresh token from storage
# session_id = request.cookies.get("mcp_session")
# if session_id:
# await storage.delete_refresh_token(session_id)
oauth_ctx = getattr(request.app.state, "oauth_context", None)
storage = oauth_ctx.get("storage") if oauth_ctx else None
if session_id and storage and oauth_ctx:
try:
user_id = await storage.get_browser_session_user(session_id)
if user_id:
token_data = await storage.get_refresh_token(user_id)
refresh_token = token_data.get("refresh_token") if token_data else None
if refresh_token:
await _revoke_refresh_token_at_idp(oauth_ctx, refresh_token)
await storage.delete_refresh_token(user_id)
logger.info("Refresh token revoked + deleted for user %s", user_id)
await storage.delete_browser_session(session_id)
except Exception as e:
# Logout must always succeed locally; log and continue.
logger.warning("Logout cleanup failed (continuing): %s", e)
response = RedirectResponse(next_url, status_code=302)
response.delete_cookie("mcp_session")
logger.info("User logged out, session cookie cleared")
return response
async def _revoke_refresh_token_at_idp(oauth_ctx: dict, refresh_token: str) -> None:
"""Best-effort RFC 7009 revocation against the IdP.
Silent on failure: revoking remotely is a defense-in-depth step on top
of deleting the local copy, and we don't want logout to error if the
IdP is unreachable or doesn't advertise a revocation endpoint.
"""
try:
discovery_url = oauth_ctx.get("discovery_url") or os.getenv(
"OIDC_DISCOVERY_URL",
f"{os.getenv('NEXTCLOUD_HOST', '')}/.well-known/openid-configuration",
)
if not discovery_url:
return
async with nextcloud_httpx_client() as http_client:
discovery_response = await http_client.get(discovery_url)
discovery_response.raise_for_status()
discovery = discovery_response.json()
revocation_endpoint = discovery.get("revocation_endpoint")
if not revocation_endpoint:
logger.debug("IdP advertises no revocation_endpoint; skipping")
return
client_id = oauth_ctx.get("client_id") or os.getenv("OIDC_CLIENT_ID")
client_secret = oauth_ctx.get("client_secret") or os.getenv(
"OIDC_CLIENT_SECRET"
)
if not (client_id and client_secret):
logger.debug("No OIDC client credentials available for revocation")
return
response = await http_client.post(
revocation_endpoint,
data={
"token": refresh_token,
"token_type_hint": "refresh_token",
},
auth=(client_id, client_secret),
)
if response.status_code >= 400:
logger.warning(
"Refresh token revocation returned HTTP %s", response.status_code
)
except Exception as e:
logger.warning("Refresh token revocation failed: %s", e)