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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
822a8fe2ed
commit
f3256e515e
@@ -743,7 +743,7 @@ async def setup_oauth_config():
|
||||
# ADR-005: Unified Token Verifier with proper audience validation
|
||||
# Use public issuer URL for JWT validation if set (handles Docker internal/external URL mismatch)
|
||||
# Tokens are issued with the public URL, but OIDC discovery returns internal URL
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
public_issuer_url = settings.nextcloud_public_issuer_url
|
||||
client_issuer = public_issuer_url if public_issuer_url else issuer
|
||||
# Get MCP server URL for audience validation
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
@@ -933,7 +933,7 @@ async def setup_oauth_config_for_multi_user_basic(
|
||||
|
||||
# Use public issuer URL for JWT validation if set (handles Docker internal/external URL mismatch)
|
||||
# Tokens are issued with the public URL, but OIDC discovery returns internal URL
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
public_issuer_url = settings.nextcloud_public_issuer_url
|
||||
client_issuer = public_issuer_url if public_issuer_url else issuer
|
||||
|
||||
# Update settings with discovered values for UnifiedTokenVerifier
|
||||
|
||||
@@ -22,6 +22,7 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_userinfo_endpoint,
|
||||
_query_idp_userinfo,
|
||||
)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
@@ -167,7 +168,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
scopes += " offline_access"
|
||||
|
||||
# Replace internal Docker hostname with public URL
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
public_issuer = get_settings().nextcloud_public_issuer_url
|
||||
if public_issuer:
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
@@ -5,19 +5,20 @@ when the client supports it, or falling back to returning the URL in a message.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from mcp.server.fastmcp import Context
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Path of the Astrolabe Nextcloud app's settings UI. The full URL is
|
||||
# reconstructed at elicitation time from NEXTCLOUD_PUBLIC_ISSUER_URL /
|
||||
# NEXTCLOUD_HOST so the user gets a browser-reachable link without needing a
|
||||
# separate config knob. If the Astrolabe app is not installed this path will
|
||||
# 404, and the user falls back to the nc_auth_provision_access tool path
|
||||
# mentioned in the same message.
|
||||
# reconstructed at elicitation time from settings.nextcloud_public_issuer_url
|
||||
# / settings.nextcloud_host so the user gets a browser-reachable link without
|
||||
# needing a separate config knob. If the Astrolabe app is not installed this
|
||||
# path will 404, and the user falls back to the nc_auth_provision_access tool
|
||||
# path mentioned in the same message.
|
||||
ASTROLABE_SETTINGS_PATH = "/index.php/apps/astrolabe/settings"
|
||||
|
||||
|
||||
@@ -40,14 +41,15 @@ class ProvisioningRequiredConfirmation(BaseModel):
|
||||
|
||||
|
||||
def _astrolabe_settings_url() -> str | None:
|
||||
"""Construct the Astrolabe settings page URL from environment.
|
||||
"""Construct the Astrolabe settings page URL from settings.
|
||||
|
||||
Prefers ``NEXTCLOUD_PUBLIC_ISSUER_URL`` (the browser-reachable public URL)
|
||||
over ``NEXTCLOUD_HOST`` (which may be an internal hostname in Docker
|
||||
Prefers ``nextcloud_public_issuer_url`` (the browser-reachable public URL)
|
||||
over ``nextcloud_host`` (which may be an internal hostname in Docker
|
||||
deployments). Returns None if neither is set.
|
||||
"""
|
||||
settings = get_settings()
|
||||
base = (
|
||||
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or os.getenv("NEXTCLOUD_HOST") or ""
|
||||
settings.nextcloud_public_issuer_url or settings.nextcloud_host or ""
|
||||
).strip()
|
||||
if not base:
|
||||
return None
|
||||
@@ -132,9 +134,10 @@ async def present_provisioning_required(ctx: Context) -> str:
|
||||
has to translate.
|
||||
|
||||
The Astrolabe settings URL is reconstructed from
|
||||
``NEXTCLOUD_PUBLIC_ISSUER_URL`` / ``NEXTCLOUD_HOST``; if Astrolabe is not
|
||||
installed the link 404s and the user falls back to the tool path
|
||||
suggested in the same message.
|
||||
``settings.nextcloud_public_issuer_url`` /
|
||||
``settings.nextcloud_host``; if Astrolabe is not installed the link
|
||||
404s and the user falls back to the tool path suggested in the same
|
||||
message.
|
||||
|
||||
Returns:
|
||||
Same string contract as :func:`present_login_url`:
|
||||
@@ -148,7 +151,7 @@ async def present_provisioning_required(ctx: Context) -> str:
|
||||
f"Open this URL to enable it via the Astrolabe app:\n\n{settings_url}\n\n"
|
||||
"If the Astrolabe app is not installed, ask your MCP client to call "
|
||||
"the `nc_auth_provision_access` tool instead — it will return a "
|
||||
"Login Flow v2 URL you can open directly.\n\n"
|
||||
"Login Flow v2 URL you can open in your browser.\n\n"
|
||||
"Then check the box below and retry the original request."
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -360,7 +360,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# Replace internal Docker hostname with public URL for browser access
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
public_issuer = get_settings().nextcloud_public_issuer_url
|
||||
if public_issuer:
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
@@ -507,7 +507,7 @@ async def oauth_authorize_nextcloud(
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# Fix internal hostname for browser access
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
public_issuer = get_settings().nextcloud_public_issuer_url
|
||||
if public_issuer:
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
@@ -15,7 +15,6 @@ Flow:
|
||||
|
||||
import html
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
@@ -251,7 +250,7 @@ async def provision_page(
|
||||
# LoginFlowV2Client) while login_url is rewritten to the public issuer
|
||||
# URL here because the browser needs a publicly-reachable address.
|
||||
login_url = init_response.login_url
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "")
|
||||
public_issuer = settings.nextcloud_public_issuer_url or ""
|
||||
if public_issuer and nextcloud_host:
|
||||
login_url = rewrite_url_origin(login_url, public_issuer.rstrip("/"))
|
||||
|
||||
|
||||
@@ -474,7 +474,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
# otherwise fall back to NEXTCLOUD_HOST from settings
|
||||
settings = get_settings()
|
||||
nextcloud_host_for_links = (
|
||||
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
|
||||
settings.nextcloud_public_issuer_url or settings.nextcloud_host
|
||||
)
|
||||
|
||||
# Build host info HTML (BasicAuth only)
|
||||
|
||||
@@ -31,6 +31,7 @@ _DEFAULTS: dict[str, Any] = {
|
||||
"nextcloud_ca_bundle": None,
|
||||
"nextcloud_mcp_server_url": None,
|
||||
"nextcloud_resource_uri": None,
|
||||
"nextcloud_public_issuer_url": None,
|
||||
# OAuth/OIDC
|
||||
"oidc_discovery_url": None,
|
||||
"nextcloud_oidc_client_id": None,
|
||||
@@ -406,6 +407,11 @@ class Settings:
|
||||
nextcloud_password: str | None = None
|
||||
nextcloud_app_password: str | None = None # Preferred over nextcloud_password
|
||||
|
||||
# Browser-reachable public URL for OAuth/Login-Flow-v2 redirects when
|
||||
# NEXTCLOUD_HOST is an internal Docker hostname. Falls back to
|
||||
# nextcloud_host when unset.
|
||||
nextcloud_public_issuer_url: str | None = None
|
||||
|
||||
# Nextcloud SSL/TLS settings
|
||||
nextcloud_verify_ssl: bool = True
|
||||
nextcloud_ca_bundle: str | None = None
|
||||
@@ -777,6 +783,7 @@ def get_settings() -> Settings:
|
||||
"nextcloud_username": "NEXTCLOUD_USERNAME",
|
||||
"nextcloud_password": "NEXTCLOUD_PASSWORD",
|
||||
"nextcloud_app_password": "NEXTCLOUD_APP_PASSWORD",
|
||||
"nextcloud_public_issuer_url": "NEXTCLOUD_PUBLIC_ISSUER_URL",
|
||||
# Nextcloud SSL/TLS settings
|
||||
"nextcloud_verify_ssl": "NEXTCLOUD_VERIFY_SSL",
|
||||
"nextcloud_ca_bundle": "NEXTCLOUD_CA_BUNDLE",
|
||||
|
||||
Reference in New Issue
Block a user