Merge pull request #686 from cbcoutinho/feat/oidc-resource-server-scope-prefix

feat: add OIDC resource server scope prefix for Cognito compatibility
This commit is contained in:
Chris Coutinho
2026-04-07 17:01:14 +02:00
committed by GitHub
5 changed files with 124 additions and 1 deletions
+1
View File
@@ -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 |
+36 -1
View File
@@ -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
@@ -109,6 +110,31 @@ _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
prefix = resource_server_id + "/"
return " ".join(
s
if s in _OIDC_STANDARD_SCOPES or s.startswith(prefix)
else f"{resource_server_id}/{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()
@@ -345,12 +371,21 @@ 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.
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}")
# 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
+2
View File
@@ -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",
+1
View File
@@ -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
+84
View File
@@ -0,0 +1,84 @@
"""Tests for OIDC resource server scope prefixing."""
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."""
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 == ""
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"
)