From e21ddd91b926a6221c51449ea8d76d816d6231d3 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 7 Apr 2026 16:33:47 +0200 Subject: [PATCH 1/3] feat: add OIDC resource server scope prefix for Cognito compatibility When OIDC_RESOURCE_SERVER_ID is set, prefix resource scopes with the identifier when forwarding to the IdP (e.g., calendar.read becomes https://example.com/calendar.read). Required for IdPs like AWS Cognito that mandate {resource_server_id}/{scope} format for custom scopes. OIDC standard scopes (openid, profile, email) are forwarded as-is. Co-Authored-By: Claude Opus 4.6 (1M context) --- nextcloud_mcp_server/auth/oauth_routes.py | 16 +++++++++++++++- nextcloud_mcp_server/config.py | 2 ++ settings.toml | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/nextcloud_mcp_server/auth/oauth_routes.py b/nextcloud_mcp_server/auth/oauth_routes.py index f8839789..4c56cd23 100644 --- a/nextcloud_mcp_server/auth/oauth_routes.py +++ b/nextcloud_mcp_server/auth/oauth_routes.py @@ -345,12 +345,26 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse: f"Rewrote authorization endpoint for browser access: {authorization_endpoint}" ) + # Prefix resource scopes with the resource server identifier if configured. + # Required for IdPs like Cognito that use {identifier}/{scope} format. + # OIDC standard scopes are forwarded as-is. + oidc_scopes = {"openid", "profile", "email"} + resource_server_id = os.getenv("OIDC_RESOURCE_SERVER_ID", "") + if resource_server_id: + idp_scope_list = [ + f"{resource_server_id}/{s}" if s not in oidc_scopes else s + for s in scopes.split() + ] + idp_scope_str = " ".join(idp_scope_list) + else: + idp_scope_str = scopes + # Redirect to Nextcloud with MCP server's own client_id (no PKCE — confidential client) idp_params = { "client_id": mcp_server_client_id, "redirect_uri": callback_uri, "response_type": "code", - "scope": scopes, + "scope": idp_scope_str, "state": server_state, "prompt": "consent", "resource": f"{mcp_server_url}/mcp", # MCP server audience diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index dd2a3f6a..9114d018 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -213,6 +213,7 @@ class Settings: oidc_client_id: str | None = None oidc_client_secret: str | None = None oidc_issuer: str | None = None + oidc_resource_server_id: str | None = None # Nextcloud settings nextcloud_host: str | None = None @@ -568,6 +569,7 @@ def get_settings() -> Settings: "oidc_client_id": "NEXTCLOUD_OIDC_CLIENT_ID", "oidc_client_secret": "NEXTCLOUD_OIDC_CLIENT_SECRET", "oidc_issuer": "OIDC_ISSUER", + "oidc_resource_server_id": "OIDC_RESOURCE_SERVER_ID", # Nextcloud settings "nextcloud_host": "NEXTCLOUD_HOST", "nextcloud_username": "NEXTCLOUD_USERNAME", diff --git a/settings.toml b/settings.toml index b4cccac1..84f5f6db 100644 --- a/settings.toml +++ b/settings.toml @@ -37,6 +37,7 @@ oidc_issuer = "@none" jwks_uri = "@none" introspection_uri = "@none" userinfo_uri = "@none" +oidc_resource_server_id = "@none" # --- Mode flags --- enable_multi_user_basic_auth = false From f67d4d1116cbcd5bfc00114e648e32981f13f2b0 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 7 Apr 2026 16:44:08 +0200 Subject: [PATCH 2/3] fix: address PR review for OIDC scope prefix feature Add offline_access to OIDC standard scopes exclusion list to prevent it from being incorrectly prefixed, which would break Cognito refresh token flows. Extract scope transformation into testable _transform_scopes_for_idp() helper, add debug logging for prefixed scopes, remove unused Settings field (oauth_routes.py consistently uses os.getenv), and add unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- nextcloud_mcp_server/auth/oauth_routes.py | 35 +++++++---- nextcloud_mcp_server/config.py | 2 - settings.toml | 1 - tests/unit/test_scope_prefix.py | 72 +++++++++++++++++++++++ 4 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_scope_prefix.py diff --git a/nextcloud_mcp_server/auth/oauth_routes.py b/nextcloud_mcp_server/auth/oauth_routes.py index 4c56cd23..10b5c09c 100644 --- a/nextcloud_mcp_server/auth/oauth_routes.py +++ b/nextcloud_mcp_server/auth/oauth_routes.py @@ -109,6 +109,28 @@ _DCR_RATE_LIMIT_MAX = 10 # max requests _DCR_RATE_LIMIT_WINDOW = 60 # per 60 seconds +# OIDC standard scopes that must never be prefixed with a resource server identifier. +_OIDC_STANDARD_SCOPES = {"openid", "profile", "email", "offline_access"} + + +def _transform_scopes_for_idp(scopes: str, resource_server_id: str) -> str: + """Prefix resource scopes with an IdP resource server identifier. + + IdPs like AWS Cognito require resource scopes in ``{identifier}/{scope}`` + format. Standard OIDC scopes (openid, profile, email, offline_access) are + forwarded unchanged. + + When *resource_server_id* is empty the original scope string is returned + as-is. + """ + if not resource_server_id: + return scopes + return " ".join( + f"{resource_server_id}/{s}" if s not in _OIDC_STANDARD_SCOPES else s + for s in scopes.split() + ) + + async def _get_cached_discovery(url: str) -> dict[str, Any]: """Fetch OIDC discovery document with caching (5-minute TTL).""" now = time.time() @@ -347,17 +369,10 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse: # Prefix resource scopes with the resource server identifier if configured. # Required for IdPs like Cognito that use {identifier}/{scope} format. - # OIDC standard scopes are forwarded as-is. - oidc_scopes = {"openid", "profile", "email"} - resource_server_id = os.getenv("OIDC_RESOURCE_SERVER_ID", "") + resource_server_id = os.getenv("OIDC_RESOURCE_SERVER_ID", "").strip() + idp_scope_str = _transform_scopes_for_idp(scopes, resource_server_id) if resource_server_id: - idp_scope_list = [ - f"{resource_server_id}/{s}" if s not in oidc_scopes else s - for s in scopes.split() - ] - idp_scope_str = " ".join(idp_scope_list) - else: - idp_scope_str = scopes + logger.info(f" IdP scopes (prefixed): {idp_scope_str}") # Redirect to Nextcloud with MCP server's own client_id (no PKCE — confidential client) idp_params = { diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index 9114d018..dd2a3f6a 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -213,7 +213,6 @@ class Settings: oidc_client_id: str | None = None oidc_client_secret: str | None = None oidc_issuer: str | None = None - oidc_resource_server_id: str | None = None # Nextcloud settings nextcloud_host: str | None = None @@ -569,7 +568,6 @@ def get_settings() -> Settings: "oidc_client_id": "NEXTCLOUD_OIDC_CLIENT_ID", "oidc_client_secret": "NEXTCLOUD_OIDC_CLIENT_SECRET", "oidc_issuer": "OIDC_ISSUER", - "oidc_resource_server_id": "OIDC_RESOURCE_SERVER_ID", # Nextcloud settings "nextcloud_host": "NEXTCLOUD_HOST", "nextcloud_username": "NEXTCLOUD_USERNAME", diff --git a/settings.toml b/settings.toml index 84f5f6db..b4cccac1 100644 --- a/settings.toml +++ b/settings.toml @@ -37,7 +37,6 @@ oidc_issuer = "@none" jwks_uri = "@none" introspection_uri = "@none" userinfo_uri = "@none" -oidc_resource_server_id = "@none" # --- Mode flags --- enable_multi_user_basic_auth = false diff --git a/tests/unit/test_scope_prefix.py b/tests/unit/test_scope_prefix.py new file mode 100644 index 00000000..cb773b49 --- /dev/null +++ b/tests/unit/test_scope_prefix.py @@ -0,0 +1,72 @@ +"""Tests for OIDC resource server scope prefixing.""" + +import pytest + +from nextcloud_mcp_server.auth.oauth_routes import _transform_scopes_for_idp + + +class TestTransformScopesForIdp: + """Test _transform_scopes_for_idp scope transformation.""" + + def test_no_prefix_when_resource_server_id_empty(self): + """Scopes are returned unchanged when resource_server_id is empty.""" + scopes = "openid profile notes.read notes.write" + assert _transform_scopes_for_idp(scopes, "") == scopes + + def test_oidc_scopes_not_prefixed(self): + """Standard OIDC scopes are never prefixed.""" + result = _transform_scopes_for_idp( + "openid profile email", "https://api.example.com" + ) + assert result == "openid profile email" + + def test_offline_access_not_prefixed(self): + """offline_access is a standard OIDC scope and must not be prefixed.""" + result = _transform_scopes_for_idp( + "openid offline_access notes.read", "https://api.example.com" + ) + assert result == "openid offline_access https://api.example.com/notes.read" + + def test_resource_scopes_prefixed(self): + """Non-OIDC scopes are prefixed with the resource server identifier.""" + result = _transform_scopes_for_idp( + "notes.read notes.write", "https://api.example.com" + ) + assert ( + result + == "https://api.example.com/notes.read https://api.example.com/notes.write" + ) + + def test_mixed_scopes(self): + """Mixed OIDC and resource scopes are handled correctly.""" + result = _transform_scopes_for_idp( + "openid profile notes.read calendar.write offline_access", + "https://api.example.com", + ) + assert result == ( + "openid profile https://api.example.com/notes.read " + "https://api.example.com/calendar.write offline_access" + ) + + @pytest.mark.parametrize( + ("resource_server_id", "expected_prefix"), + [ + ("https://api.example.com", "https://api.example.com/notes.read"), + ("my-api", "my-api/notes.read"), + ("urn:api:prod", "urn:api:prod/notes.read"), + ], + ) + def test_various_identifier_formats(self, resource_server_id, expected_prefix): + """Different resource server identifier formats are supported.""" + result = _transform_scopes_for_idp("notes.read", resource_server_id) + assert result == expected_prefix + + def test_single_resource_scope(self): + """A single non-OIDC scope is prefixed.""" + result = _transform_scopes_for_idp("notes.read", "https://api.example.com") + assert result == "https://api.example.com/notes.read" + + def test_empty_scopes_string(self): + """An empty scopes string returns empty.""" + result = _transform_scopes_for_idp("", "https://api.example.com") + assert result == "" From cc6ba65993358680d4fe6795c5c38cfe026ad43a Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 7 Apr 2026 16:58:29 +0200 Subject: [PATCH 3/3] fix: address second round of PR review for scope prefix - Use dynaconf (get_settings()) instead of os.getenv for OIDC_RESOURCE_SERVER_ID - Re-add Settings field, _field_map entry, and settings.toml default - Add trailing-slash guard (.rstrip("/")) to prevent double-slash in scopes - Add double-prefixing guard: skip scopes already carrying the prefix - Add @pytest.mark.unit to test module - Add test for already-prefixed scopes - Document OIDC_RESOURCE_SERVER_ID in docs/configuration.md Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/configuration.md | 1 + nextcloud_mcp_server/auth/oauth_routes.py | 10 ++++++++-- nextcloud_mcp_server/config.py | 2 ++ settings.toml | 1 + tests/unit/test_scope_prefix.py | 12 ++++++++++++ 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 26e3f300..787013f0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -123,6 +123,7 @@ NEXTCLOUD_PASSWORD= | `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional | - | OAuth client ID (auto-registers if empty) | | `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional | - | OAuth client secret (auto-registers if empty) | | `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks | +| `OIDC_RESOURCE_SERVER_ID` | ⚠️ Optional | - | Resource server identifier for IdPs that require prefixed scopes (e.g., AWS Cognito). When set, resource scopes are sent as `{id}/{scope}` | | `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty to enable OAuth mode | | `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty to enable OAuth mode | diff --git a/nextcloud_mcp_server/auth/oauth_routes.py b/nextcloud_mcp_server/auth/oauth_routes.py index 10b5c09c..8ebdf31a 100644 --- a/nextcloud_mcp_server/auth/oauth_routes.py +++ b/nextcloud_mcp_server/auth/oauth_routes.py @@ -37,6 +37,7 @@ from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback from nextcloud_mcp_server.auth.client_registry import get_client_registry from nextcloud_mcp_server.auth.storage import RefreshTokenStorage +from nextcloud_mcp_server.config import get_settings from ..http import nextcloud_httpx_client @@ -125,8 +126,11 @@ def _transform_scopes_for_idp(scopes: str, resource_server_id: str) -> str: """ if not resource_server_id: return scopes + prefix = resource_server_id + "/" return " ".join( - f"{resource_server_id}/{s}" if s not in _OIDC_STANDARD_SCOPES else s + s + if s in _OIDC_STANDARD_SCOPES or s.startswith(prefix) + else f"{resource_server_id}/{s}" for s in scopes.split() ) @@ -369,7 +373,9 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse: # Prefix resource scopes with the resource server identifier if configured. # Required for IdPs like Cognito that use {identifier}/{scope} format. - resource_server_id = os.getenv("OIDC_RESOURCE_SERVER_ID", "").strip() + resource_server_id = ( + (get_settings().oidc_resource_server_id or "").strip().rstrip("/") + ) idp_scope_str = _transform_scopes_for_idp(scopes, resource_server_id) if resource_server_id: logger.info(f" IdP scopes (prefixed): {idp_scope_str}") diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index dd2a3f6a..9114d018 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -213,6 +213,7 @@ class Settings: oidc_client_id: str | None = None oidc_client_secret: str | None = None oidc_issuer: str | None = None + oidc_resource_server_id: str | None = None # Nextcloud settings nextcloud_host: str | None = None @@ -568,6 +569,7 @@ def get_settings() -> Settings: "oidc_client_id": "NEXTCLOUD_OIDC_CLIENT_ID", "oidc_client_secret": "NEXTCLOUD_OIDC_CLIENT_SECRET", "oidc_issuer": "OIDC_ISSUER", + "oidc_resource_server_id": "OIDC_RESOURCE_SERVER_ID", # Nextcloud settings "nextcloud_host": "NEXTCLOUD_HOST", "nextcloud_username": "NEXTCLOUD_USERNAME", diff --git a/settings.toml b/settings.toml index b4cccac1..84f5f6db 100644 --- a/settings.toml +++ b/settings.toml @@ -37,6 +37,7 @@ oidc_issuer = "@none" jwks_uri = "@none" introspection_uri = "@none" userinfo_uri = "@none" +oidc_resource_server_id = "@none" # --- Mode flags --- enable_multi_user_basic_auth = false diff --git a/tests/unit/test_scope_prefix.py b/tests/unit/test_scope_prefix.py index cb773b49..275ad529 100644 --- a/tests/unit/test_scope_prefix.py +++ b/tests/unit/test_scope_prefix.py @@ -4,6 +4,8 @@ import pytest from nextcloud_mcp_server.auth.oauth_routes import _transform_scopes_for_idp +pytestmark = pytest.mark.unit + class TestTransformScopesForIdp: """Test _transform_scopes_for_idp scope transformation.""" @@ -70,3 +72,13 @@ class TestTransformScopesForIdp: """An empty scopes string returns empty.""" result = _transform_scopes_for_idp("", "https://api.example.com") assert result == "" + + def test_already_prefixed_scopes_not_double_prefixed(self): + """Scopes already carrying the resource server prefix are not prefixed again.""" + result = _transform_scopes_for_idp( + "https://api.example.com/notes.read notes.write", + "https://api.example.com", + ) + assert result == ( + "https://api.example.com/notes.read https://api.example.com/notes.write" + )