feat(auth): advertise offline_access in discovered OAuth scopes

discover_all_scopes() builds the scopes_supported lists exposed at
/.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server.
It previously emitted only the base OIDC scopes plus tool-derived
@require_scopes, so offline_access was never advertised and
discovery-driven MCP clients had no way to know they could request a
refresh token.

Add offline_access unconditionally. The AS proxy already forwards
client-requested scopes upstream to Nextcloud, which issues a refresh
token when the MCP server's OIDC client is permitted the scope. This
only changes what is advertised; it is not added to ALL_SUPPORTED_SCOPES
(the app-level permission set), since offline_access is an OIDC behavior
rather than a Nextcloud resource permission.

Add a regression test asserting offline_access is always present in
discover_all_scopes() output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-11 15:30:41 +02:00
co-authored by Claude Opus 4.8
parent fcbf6e1486
commit c30a795a1f
2 changed files with 33 additions and 1 deletions
+24
View File
@@ -1,9 +1,11 @@
"""Unit tests for scope decorator metadata and classification logic."""
import pytest
from mcp.server.fastmcp import FastMCP
from nextcloud_mcp_server.auth.scope_authorization import (
InsufficientScopeError,
discover_all_scopes,
require_scopes,
)
@@ -63,3 +65,25 @@ def test_insufficient_scope_error_with_custom_message():
assert error.missing_scopes == missing
assert str(error) == custom_msg
@pytest.mark.unit
def test_discover_all_scopes_always_includes_offline_access():
"""offline_access is advertised so discovery-driven clients can request a refresh token.
It is not tied to any tool's @require_scopes, so it must be present even on
an MCP instance with a single unrelated tool. Guards the metadata exposed at
/.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server.
"""
mcp = FastMCP(name="test-scope-discovery")
@mcp.tool()
@require_scopes("notes.read")
async def example_tool():
pass
scopes = discover_all_scopes(mcp)
assert "offline_access" in scopes
# Base OIDC scopes and tool-derived scopes still come through.
assert {"openid", "profile", "email", "notes.read"}.issubset(scopes)