fix: conditionally include offline_access based on IdP discovery

AWS Cognito provides refresh tokens automatically with the authorization
code flow but does not list offline_access as a supported scope. Check
the IdP's scopes_supported discovery field before including it in
requests, and always accept refresh tokens from responses regardless.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-07 23:48:49 +02:00
co-authored by Claude Opus 4.6
parent 776e2ce693
commit 7730f926cb
5 changed files with 331 additions and 10 deletions
@@ -85,10 +85,11 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
mcp_server_url = oauth_config["mcp_server_url"]
callback_uri = f"{mcp_server_url}/oauth/callback"
# Request only basic OIDC scopes for browser session
# Request only basic OIDC scopes for browser session.
# offline_access is added conditionally below based on IdP discovery.
# Note: Nextcloud app scopes (notes.read, etc.) are for MCP client access tokens,
# not for the MCP server's own browser authentication
scopes = "openid profile email offline_access"
scopes = "openid profile email"
# Generate PKCE values for ALL modes (both external and integrated IdP require PKCE)
code_verifier = secrets.token_urlsafe(32)
@@ -113,6 +114,12 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
if not oauth_client.authorization_endpoint:
await oauth_client.discover()
# Check if IdP supports offline_access via server metadata from discovery
idp_metadata = getattr(oauth_client, "server_metadata", None) or {}
idp_scopes = idp_metadata.get("scopes_supported")
if idp_scopes is None or "offline_access" in idp_scopes:
scopes += " offline_access"
# Get Nextcloud resource URI for audience (background sync needs Nextcloud-scoped tokens)
nextcloud_resource_uri = oauth_config.get(
"nextcloud_resource_uri", oauth_config.get("nextcloud_host")
@@ -151,6 +158,14 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
discovery = response.json()
authorization_endpoint = discovery["authorization_endpoint"]
# Include offline_access only if the IdP advertises it (or if
# scopes_supported is absent from the discovery document).
# IdPs like AWS Cognito provide refresh tokens automatically without
# supporting the offline_access scope.
idp_scopes = discovery.get("scopes_supported")
if idp_scopes is None or "offline_access" in idp_scopes:
scopes += " offline_access"
# Replace internal Docker hostname with public URL
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
+11 -1
View File
@@ -456,7 +456,17 @@ async def oauth_authorize_nextcloud(
# Resource scopes are requested by client in Flow 1
scopes = "openid profile email"
if get_settings().enable_offline_access:
scopes += " offline_access"
# Only include offline_access if the IdP advertises it in scopes_supported.
# IdPs like AWS Cognito provide refresh tokens automatically without
# supporting the offline_access scope.
discovery_url = oauth_config.get("discovery_url")
if discovery_url:
disc = await _get_cached_discovery(discovery_url)
scopes_supported = disc.get("scopes_supported")
if scopes_supported is None or "offline_access" in scopes_supported:
scopes += " offline_access"
else:
scopes += " offline_access"
# Generate PKCE values (required by Nextcloud OIDC)
code_verifier = secrets.token_urlsafe(32)
+43 -6
View File
@@ -169,6 +169,24 @@ class TokenBrokerService:
self._oidc_config = response.json()
return self._oidc_config
async def _idp_supports_offline_access(self) -> bool:
"""Check if the IdP advertises ``offline_access`` in ``scopes_supported``.
Returns ``True`` when ``offline_access`` is explicitly listed **or**
when ``scopes_supported`` is absent from the discovery document (the
field is OPTIONAL per the OIDC spec, so absence means unknown —
include ``offline_access`` as a safe default).
Returns ``False`` when ``scopes_supported`` is present but does **not**
include ``offline_access`` (e.g. AWS Cognito, which provides refresh
tokens automatically without requiring the scope).
"""
config = await self._get_oidc_config()
scopes_supported = config.get("scopes_supported")
if scopes_supported is None:
return True
return "offline_access" in scopes_supported
async def get_nextcloud_token(self, user_id: str) -> Optional[str]:
"""
Get a valid Nextcloud access token for the user.
@@ -334,10 +352,18 @@ class TokenBrokerService:
# Request new access token using refresh token
# Include client credentials as required by most OAuth servers
# Only request offline_access if the IdP advertises it (e.g. Cognito does not)
base_scopes = ["openid", "profile", "email"]
if await self._idp_supports_offline_access():
base_scopes.append("offline_access")
scope_str = " ".join(
base_scopes
+ ["notes.read", "notes.write", "calendar.read", "calendar.write"]
)
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "openid profile email offline_access notes.read notes.write calendar.read calendar.write",
"scope": scope_str,
"client_id": self.client_id,
"client_secret": self.client_secret,
}
@@ -402,10 +428,13 @@ class TokenBrokerService:
client = await self._get_http_client()
# Always include basic OpenID scopes + offline_access to get new refresh token
scopes = list(
set(["openid", "profile", "email", "offline_access"] + required_scopes)
)
# Always include basic OpenID scopes; only add offline_access if the IdP
# advertises it (e.g. AWS Cognito provides refresh tokens automatically
# without supporting the offline_access scope).
base_scopes = ["openid", "profile", "email"]
if await self._idp_supports_offline_access():
base_scopes.append("offline_access")
scopes = list(set(base_scopes + required_scopes))
# Request new access token with specific scopes
# Include client credentials as required by most OAuth servers
@@ -518,10 +547,18 @@ class TokenBrokerService:
client = await self._get_http_client()
# Request new refresh token
# Only request offline_access if the IdP advertises it
base_scopes = ["openid", "profile", "email"]
if await self._idp_supports_offline_access():
base_scopes.append("offline_access")
scope_str = " ".join(
base_scopes
+ ["notes.read", "notes.write", "calendar.read", "calendar.write"]
)
data = {
"grant_type": "refresh_token",
"refresh_token": current_refresh_token,
"scope": "openid profile email offline_access notes.read notes.write calendar.read calendar.write",
"scope": scope_str,
}
response = await client.post(
+6 -1
View File
@@ -468,11 +468,14 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
)
# Define scopes for Nextcloud access
# Note: offline_access is only included when enabled in settings.
# The actual scope sent to the IdP is determined by
# oauth_authorize_nextcloud() based on IdP discovery, so this list
# is informational (generate_oauth_url_for_flow2 marks it as unused).
scopes = [
"openid",
"profile",
"email",
"offline_access", # Critical for background operations
"notes.read",
"notes.write",
"calendar.read",
@@ -482,6 +485,8 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
"files.read",
"files.write",
]
if get_settings().enable_offline_access:
scopes.insert(3, "offline_access")
# Generate authorization URL
auth_url = generate_oauth_url_for_flow2(