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:
co-authored by
Claude Opus 4.7
parent
83f2e88d2c
commit
15dbb26349
@@ -1133,6 +1133,90 @@ class RefreshTokenStorage:
|
||||
|
||||
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 aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO browser_sessions
|
||||
(session_id, user_id, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(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,
|
||||
)
|
||||
|
||||
async def get_browser_session_user(self, session_id: str) -> Optional[str]:
|
||||
"""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 aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
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()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM browser_sessions WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
logger.debug("Deleted browser session %s", session_id[:8])
|
||||
return deleted
|
||||
|
||||
# ============================================================================
|
||||
# Webhook Registration Tracking (both BasicAuth and OAuth modes)
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user