Skip to content
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ services:
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
- NEXTCLOUD_RESOURCE_URI=nextcloud # ADR-005: Keycloak uses client IDs as audiences, not URLs
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
# External-IdP mode: the OAuth issuer (above) is Keycloak, but Login Flow
# v2 must send the browser to *Nextcloud*. NEXTCLOUD_HOST is the internal
# Docker hostname, so give the browser-reachable Nextcloud URL explicitly.
- NEXTCLOUD_PUBLIC_URL=http://localhost:8080

# Refresh token storage (ADR-002 Tier 1 & 2). Source from .env.
- ENABLE_BACKGROUND_OPERATIONS=true
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ NEXTCLOUD_PUBLIC_ISSUER_URL=https://your.nextcloud.instance.com
| `TOKEN_ENCRYPTION_KEY` | ✅ Yes | Fernet key for app-password encryption — generate with `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` |
| `TOKEN_STORAGE_DB` | ✅ Yes | Path to SQLite DB for stored app passwords (use a persistent volume) |
| `NEXTCLOUD_MCP_SERVER_URL` | ✅ Yes | Public URL of the MCP server (used as the audience claim and for browser redirects) |
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | ✅ Yes | Public URL of Nextcloud (for browser redirects during Login Flow v2) |
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | ✅ Yes | Public URL used as the OAuth issuer for JWT validation **and** (by default) the browser-reachable Nextcloud URL for Login Flow v2 redirects. When Nextcloud is its own IdP these coincide. |
| `NEXTCLOUD_PUBLIC_URL` | Optional (required for external IdPs) | Browser-reachable public URL of **Nextcloud** for Login Flow v2 login pages and elicitation links. Only needed when the OAuth issuer is a *separate* IdP (e.g. Keycloak/Cognito): there `NEXTCLOUD_PUBLIC_ISSUER_URL` points at the IdP, so set this to Nextcloud's own URL or the Login Flow v2 login page is built on the IdP origin and 404s. Falls back to `NEXTCLOUD_PUBLIC_ISSUER_URL` then `NEXTCLOUD_HOST` when unset. |
| `NEXTCLOUD_OIDC_CLIENT_ID` | ✅ Strongly recommended | OIDC client ID for the MCP server's relying-party registration with the IdP (Nextcloud's built-in OIDC by default; Keycloak / Cognito / etc. via `OIDC_DISCOVERY_URL`). If unset and the IdP advertises a `registration_endpoint`, the server falls back to RFC 7591 Dynamic Client Registration (DCR) — **but with Nextcloud's built-in `oidc` app this fallback breaks after ~1 hour** (see warning below). Create a static client and set this instead. |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ✅ Strongly recommended | OIDC client secret paired with `NEXTCLOUD_OIDC_CLIENT_ID`. |
| `OIDC_DISCOVERY_URL` | Optional | Override the IdP discovery URL. Defaults to `${NEXTCLOUD_HOST}/.well-known/openid-configuration` (Nextcloud's built-in OIDC). Set to a Keycloak realm or AWS Cognito user-pool discovery URL to use an external IdP. |
Expand Down
6 changes: 5 additions & 1 deletion docs/login-flow-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,11 @@ The MCP server cannot reach Nextcloud at `NEXTCLOUD_HOST`. Verify network connec

### "Login URL points to localhost in browser"

`NEXTCLOUD_PUBLIC_ISSUER_URL` is missing or wrong. Set it to the public URL of Nextcloud as the user's browser sees it. The server rewrites the login URL's origin from the internal `NEXTCLOUD_HOST` to `NEXTCLOUD_PUBLIC_ISSUER_URL` before redirecting the browser.
`NEXTCLOUD_PUBLIC_ISSUER_URL` is missing or wrong. Set it to the public URL of Nextcloud as the user's browser sees it. The server rewrites the login URL's origin from the internal `NEXTCLOUD_HOST` to the browser-reachable Nextcloud URL before redirecting the browser.

### Login page 404s / lands on the IdP (external IdP, e.g. Keycloak)

With an **external** IdP, `NEXTCLOUD_PUBLIC_ISSUER_URL` points at the IdP (it doubles as the OAuth issuer for JWT validation), so the Login Flow v2 login URL gets rewritten onto the IdP's origin — which has no `/login/v2` endpoint and 404s. Set **`NEXTCLOUD_PUBLIC_URL`** to Nextcloud's own browser-reachable URL; it takes precedence over `NEXTCLOUD_PUBLIC_ISSUER_URL` for the login-page and elicitation-link rewrites while leaving JWT issuer validation on the IdP. Single-IdP (Nextcloud-is-the-IdP) deployments don't need it — the issuer URL already resolves to Nextcloud.

### Stored app password rejected by Nextcloud (401)

Expand Down
24 changes: 11 additions & 13 deletions nextcloud_mcp_server/auth/elicitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
logger = logging.getLogger(__name__)

# Path of the Astrolabe Nextcloud app's settings UI. The full URL is
# reconstructed at elicitation time from settings.nextcloud_public_issuer_url
# / settings.nextcloud_host so the user gets a browser-reachable link without
# reconstructed at elicitation time from settings.nextcloud_browser_url (the
# browser-reachable Nextcloud base URL) so the user gets a working 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.
Expand Down Expand Up @@ -44,17 +44,16 @@ class ProvisioningRequiredConfirmation(BaseModel):
def _astrolabe_settings_url() -> str | None:
"""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
deployments). Returns None if neither is set (or set to the empty
string), or if the configured base URL is missing an http:// or
https:// scheme — in the latter case the caller renders the tool-only
fallback message instead of a broken link.
Uses ``nextcloud_browser_url`` (the browser-reachable Nextcloud base URL:
``nextcloud_public_url`` → ``nextcloud_public_issuer_url`` → ``nextcloud_host``)
so the link points at Nextcloud even in external-IdP deployments where the
OAuth issuer URL is the IdP, not Nextcloud. Returns None if none is set (or
set to the empty string), or if the configured base URL is missing an
http:// or https:// scheme — in the latter case the caller renders the
tool-only fallback message instead of a broken link.
"""
settings = get_settings()
base = (
settings.nextcloud_public_issuer_url or settings.nextcloud_host or ""
).strip()
base = (settings.nextcloud_browser_url or "").strip()
if not base:
return None
if not base.startswith(("http://", "https://")):
Expand Down Expand Up @@ -182,8 +181,7 @@ async def present_provisioning_required(ctx: Context) -> str:
has to translate.

The Astrolabe settings URL is reconstructed from
``settings.nextcloud_public_issuer_url`` /
``settings.nextcloud_host``; if Astrolabe is not installed the link
``settings.nextcloud_browser_url``; if Astrolabe is not installed the link
404s and the user falls back to the tool path suggested in the same
message.

Expand Down
16 changes: 9 additions & 7 deletions nextcloud_mcp_server/auth/provision_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ async def _poll_and_store(provision_id: str) -> None:
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
public_host=settings.nextcloud_public_issuer_url,
public_host=settings.nextcloud_browser_url,
)

poll_endpoint = session["poll_endpoint"]
Expand Down Expand Up @@ -214,7 +214,7 @@ async def provision_page(
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
public_host=settings.nextcloud_public_issuer_url,
public_host=settings.nextcloud_browser_url,
)
init_response = await flow_client.initiate()
except Exception as e:
Expand Down Expand Up @@ -262,12 +262,14 @@ async def provision_page(
# The login_url may use the internal Docker hostname (http://app/...).
# Replace with the public Nextcloud URL for the browser.
# Note: poll_endpoint is rewritten to NEXTCLOUD_HOST (server-side, in
# LoginFlowV2Client) while login_url is rewritten to the public issuer
# URL here because the browser needs a publicly-reachable address.
# LoginFlowV2Client) while login_url is rewritten to the public *Nextcloud*
# URL here because the browser needs a publicly-reachable address. This must
# use nextcloud_browser_url (not the OAuth issuer URL): in external-IdP mode
# the issuer is the IdP, which has no Login Flow v2 endpoint.
login_url = init_response.login_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("/"))
public_browser_url = settings.nextcloud_browser_url or ""
if public_browser_url and nextcloud_host:
login_url = rewrite_url_origin(login_url, public_browser_url.rstrip("/"))

return RedirectResponse(login_url)

Expand Down
11 changes: 5 additions & 6 deletions nextcloud_mcp_server/auth/userinfo_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,13 +482,12 @@ async def user_info_html(request: Request) -> HTMLResponse:
str(request.url_for("oauth_logout")) if oauth_ctx else "/oauth/logout"
)

# Get Nextcloud host for generating links to apps (used by viz tab)
# Use public issuer URL if available (for browser-accessible links),
# otherwise fall back to NEXTCLOUD_HOST from settings
# Get Nextcloud host for generating browser-accessible links to apps (viz
# tab). Use nextcloud_browser_url so the links point at Nextcloud even in
# external-IdP mode, where nextcloud_public_issuer_url is the IdP, not
# Nextcloud (falls back to public_issuer_url → nextcloud_host).
settings = get_settings()
nextcloud_host_for_links = (
settings.nextcloud_public_issuer_url or settings.nextcloud_host
)
nextcloud_host_for_links = settings.nextcloud_browser_url

# Build host info HTML (BasicAuth only)
host_info_html = ""
Expand Down
39 changes: 39 additions & 0 deletions nextcloud_mcp_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"nextcloud_mcp_server_url": None,
"nextcloud_resource_uri": None,
"nextcloud_public_issuer_url": None,
"nextcloud_public_url": None,
"cookie_secure": None,
# OAuth/OIDC
"oidc_discovery_url": None,
Expand Down Expand Up @@ -734,8 +735,25 @@ class Settings:
# 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.
#
# NOTE: this doubles as the OAuth *issuer* URL used for JWT ``iss``
# validation. In external-IdP mode (e.g. Keycloak) the issuer is the IdP,
# NOT Nextcloud — so this value points at the IdP, not the browser-reachable
# Nextcloud host. Use ``nextcloud_public_url`` / ``nextcloud_browser_url``
# for anything that must resolve to Nextcloud itself (Login Flow v2 login
# URLs, elicitation links).
nextcloud_public_issuer_url: str | None = None

# Browser-reachable public URL of the *Nextcloud* instance, used to rewrite
# Login Flow v2 login URLs and elicitation links when NEXTCLOUD_HOST is an
# internal Docker hostname. Distinct from ``nextcloud_public_issuer_url``
# because, in external-IdP (Keycloak/OIDC) deployments, the OAuth issuer is
# the IdP while Login Flow v2 must still point the browser at Nextcloud.
# Falls back to ``nextcloud_public_issuer_url`` then ``nextcloud_host`` (see
# ``nextcloud_browser_url``) so single-IdP (login-flow) deployments that set
# only NEXTCLOUD_PUBLIC_ISSUER_URL keep working unchanged.
nextcloud_public_url: str | None = None

# Browser cookie Secure flag. None = auto-detect from nextcloud_host
# scheme (https → True, else False). Set COOKIE_SECURE=true/false to
# override.
Expand Down Expand Up @@ -1006,6 +1024,26 @@ class Settings:
# control plane to pull. See nextcloud_mcp_server/usage/store.py.
usage_metering_enabled: bool = False

@property
def nextcloud_browser_url(self) -> str | None:
"""Browser-reachable base URL of the Nextcloud instance.

Resolves the URL the *user's browser* must use to reach Nextcloud for
Login Flow v2 login pages and elicitation links. Prefers the dedicated
``nextcloud_public_url``; falls back to ``nextcloud_public_issuer_url``
(correct in single-IdP / login-flow deployments where the OAuth issuer
IS Nextcloud) and finally the internal ``nextcloud_host``.

In external-IdP mode (e.g. Keycloak) set ``NEXTCLOUD_PUBLIC_URL`` so this
does not fall back to the IdP issuer URL, which would send the browser to
the IdP instead of Nextcloud.
"""
return (
self.nextcloud_public_url
or self.nextcloud_public_issuer_url
or self.nextcloud_host
)

def __post_init__(self):
"""Validate configuration and set defaults."""

Expand Down Expand Up @@ -1588,6 +1626,7 @@ def get_settings() -> Settings:
"nextcloud_password": "NEXTCLOUD_PASSWORD",
"nextcloud_app_password": "NEXTCLOUD_APP_PASSWORD",
"nextcloud_public_issuer_url": "NEXTCLOUD_PUBLIC_ISSUER_URL",
"nextcloud_public_url": "NEXTCLOUD_PUBLIC_URL",
"cookie_secure": "COOKIE_SECURE",
# Nextcloud SSL/TLS settings
"nextcloud_verify_ssl": "NEXTCLOUD_VERIFY_SSL",
Expand Down
6 changes: 3 additions & 3 deletions nextcloud_mcp_server/server/auth_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ async def nc_auth_provision_access(
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
public_host=settings.nextcloud_public_issuer_url,
public_host=settings.nextcloud_browser_url,
)
init_response = await flow_client.initiate()
except Exception as e:
Expand Down Expand Up @@ -259,7 +259,7 @@ async def nc_auth_check_status(
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
public_host=settings.nextcloud_public_issuer_url,
public_host=settings.nextcloud_browser_url,
)
poll_result = await flow_client.poll(
poll_endpoint=session["poll_endpoint"],
Expand Down Expand Up @@ -441,7 +441,7 @@ async def nc_auth_update_scopes(
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
public_host=settings.nextcloud_public_issuer_url,
public_host=settings.nextcloud_browser_url,
)
init_response = await flow_client.initiate()
except Exception as e:
Expand Down
Loading