refactor(config)!: rename OAUTH_SINGLE_AUDIENCE to LOGIN_FLOW, gate on ENABLE_LOGIN_FLOW

The AuthMode.OAUTH_SINGLE_AUDIENCE enum was a vestige of ADR-021's
original design where it co-existed with OAUTH_TOKEN_EXCHANGE. The
un-augmented OAuth bearer pass-through it represented relied on
Nextcloud-side patches to user_oidc (Bearer token validation on
non-OCS endpoints) that were never merged upstream (see
docs/authentication.md, docs/login-flow-v2.md). The working path —
mcp-login-flow profile — sets ENABLE_LOGIN_FLOW=true on top of this
mode so Login Flow v2 acquires per-user Nextcloud app passwords via
a browser flow. With OAUTH_TOKEN_EXCHANGE removed in 57303135, the
_AUDIENCE suffix in the Python name no longer disambiguates anything,
and the enum value diverged from the env-var spelling. ADR-022 (now
accepted) called for this rename as step 1 of consolidation.

- nextcloud_mcp_server/config_validators.py: rename enum to LOGIN_FLOW
  with value "login_flow". The mode_map key is now "login_flow"; the
  MODE_REQUIREMENTS entry requires `enable_login_flow=True`. Added a
  validation gate so MCP_DEPLOYMENT_MODE=login_flow without
  ENABLE_LOGIN_FLOW=true errors with a clear message pointing at
  ADR-022. Default auto-detection fallback returns LOGIN_FLOW.
- nextcloud_mcp_server/app.py: renamed three identifier references and
  switched the "Configuring MCP server for OAuth mode" log line to
  the uniform `mode.value` shape used by the other modes.
- nextcloud_mcp_server/api/management.py: renamed identifier in the
  /api/v1/status mapping. The user-visible "auth_mode": "oauth" string
  is preserved — that's a stable Astrolabe contract.
- nextcloud_mcp_server/config.py: updated Settings docstring.
- tests/unit/test_config_validators.py: renamed class
  TestOAuthSingleAudienceValidation → TestLoginFlowValidation,
  individual test methods, env-var strings; added enable_login_flow=True
  to fixtures expecting success; added a new test
  (test_login_flow_requires_enable_login_flow_flag) that exercises the
  validation gate.
- tests/unit/test_management_status_endpoint.py: renamed identifier.

BREAKING CHANGE: MCP_DEPLOYMENT_MODE=oauth_single_audience is no longer
accepted. Set MCP_DEPLOYMENT_MODE=login_flow (and keep
ENABLE_LOGIN_FLOW=true) for the same deployment. The un-augmented
OAuth path is no longer supported; if you previously ran the broken
path, you can either configure Login Flow v2 (recommended) or switch
to multi_user_basic / single_user_basic.

Dead-code pruning of `oauth_enabled and not enable_login_flow`
branches in app.py (lifespan, background sync) is deferred to a
separate follow-up PR per the consolidation plan in ADR-022.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-12 19:34:13 +02:00
co-authored by Claude Opus 4.7
parent 586859d66a
commit cafd318f36
6 changed files with 90 additions and 50 deletions
+47 -28
View File
@@ -43,14 +43,14 @@ class TestModeDetection:
mode = detect_auth_mode(settings)
assert mode == AuthMode.SINGLE_USER_BASIC
def test_oauth_single_audience_default(self):
"""Test OAuth single-audience is default mode."""
def test_login_flow_default(self):
"""Test Login Flow v2 is the default multi-user mode."""
settings = Settings(
nextcloud_host="http://localhost",
)
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
class TestSingleUserBasicValidation:
@@ -108,7 +108,7 @@ class TestSingleUserBasicValidation:
# Mode detection requires BOTH username AND password for single-user BasicAuth
# If only one is present, it defaults to OAuth single-audience
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
# In OAuth mode, having a password set is forbidden
assert any("nextcloud_password" in err.lower() for err in errors)
@@ -123,7 +123,7 @@ class TestSingleUserBasicValidation:
# Mode detection requires BOTH username AND password for single-user BasicAuth
# If only one is present, it defaults to OAuth single-audience
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
# In OAuth mode, having a username set is forbidden
assert any("nextcloud_username" in err.lower() for err in errors)
@@ -285,37 +285,40 @@ class TestMultiUserBasicValidation:
assert settings.enable_offline_access is True
class TestOAuthSingleAudienceValidation:
"""Test validation for OAuth single-audience mode."""
class TestLoginFlowValidation:
"""Test validation for Login Flow v2 mode (formerly OAUTH_SINGLE_AUDIENCE)."""
def test_valid_minimal_config(self):
"""Test valid minimal OAuth single-audience config."""
"""Test valid minimal Login Flow v2 config."""
settings = Settings(
nextcloud_host="http://localhost",
enable_login_flow=True,
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
assert len(errors) == 0
def test_valid_with_static_credentials(self):
"""Test valid config with static OAuth credentials."""
settings = Settings(
nextcloud_host="http://localhost",
enable_login_flow=True,
oidc_client_id="test-client",
oidc_client_secret="test-secret",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
assert len(errors) == 0
def test_valid_with_offline_access(self):
"""Test valid config with offline access."""
settings = Settings(
nextcloud_host="http://localhost",
enable_login_flow=True,
oidc_client_id="test-client",
oidc_client_secret="test-secret",
enable_offline_access=True,
@@ -325,7 +328,7 @@ class TestOAuthSingleAudienceValidation:
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
assert len(errors) == 0
def test_forbidden_username_password(self):
@@ -345,29 +348,45 @@ class TestOAuthSingleAudienceValidation:
"""Test error when offline access enabled but encryption key missing."""
settings = Settings(
nextcloud_host="http://localhost",
enable_login_flow=True,
enable_offline_access=True,
token_storage_db="/tmp/tokens.db",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
assert any("token_encryption_key" in err.lower() for err in errors)
def test_vector_sync_auto_enables_background_ops_in_oauth_mode(self):
"""Test vector sync automatically enables background operations in OAuth mode (ADR-021)."""
def test_login_flow_requires_enable_login_flow_flag(self):
"""ADR-022: LOGIN_FLOW mode must error when ENABLE_LOGIN_FLOW is not true."""
settings = Settings(
nextcloud_host="http://localhost",
# enable_login_flow deliberately omitted (defaults to False)
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.LOGIN_FLOW
assert any("ENABLE_LOGIN_FLOW" in err for err in errors), (
f"Expected ENABLE_LOGIN_FLOW gate error, got: {errors}"
)
def test_vector_sync_auto_enables_background_ops_in_login_flow_mode(self):
"""Test vector sync automatically enables background operations in Login Flow v2 mode (ADR-021)."""
# Before ADR-021: This would have failed validation (required explicit ENABLE_OFFLINE_ACCESS)
# After ADR-021: vector_sync_enabled auto-enables background operations in multi-user modes
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"ENABLE_LOGIN_FLOW": "true",
"VECTOR_SYNC_ENABLED": "true",
"QDRANT_LOCATION": ":memory:",
"OLLAMA_BASE_URL": "http://ollama:11434",
"TOKEN_ENCRYPTION_KEY": "test-key",
"TOKEN_STORAGE_DB": "/tmp/test.db",
# Note: No username/password = OAuth mode
# Note: No username/password = Login Flow v2 multi-user OAuth mode
},
clear=True,
):
@@ -377,7 +396,7 @@ class TestOAuthSingleAudienceValidation:
settings = get_settings()
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
# Should have no errors - background operations auto-enabled
assert len(errors) == 0
# Verify background operations were auto-enabled
@@ -515,8 +534,8 @@ class TestConfigurationConsolidation:
settings = get_settings()
assert settings.enable_offline_access is True
def test_semantic_search_auto_enables_background_ops_in_oauth_mode(self):
"""Test ENABLE_SEMANTIC_SEARCH automatically enables background operations in OAuth mode."""
def test_semantic_search_auto_enables_background_ops_in_login_flow_mode(self):
"""Test ENABLE_SEMANTIC_SEARCH automatically enables background operations in Login Flow v2 mode."""
with patch.dict(
os.environ,
{
@@ -707,13 +726,13 @@ class TestExplicitModeSelection:
assert mode == AuthMode.MULTI_USER_BASIC
def test_explicit_oauth_single_audience_mode(self):
"""Test explicit oauth_single_audience mode selection."""
def test_explicit_login_flow_mode(self):
"""Test explicit login_flow mode selection."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": "oauth_single_audience",
"MCP_DEPLOYMENT_MODE": "login_flow",
},
clear=True,
):
@@ -723,7 +742,7 @@ class TestExplicitModeSelection:
settings = get_settings()
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
def test_invalid_deployment_mode_raises_error(self):
"""Test invalid MCP_DEPLOYMENT_MODE raises ValueError."""
@@ -757,7 +776,7 @@ class TestExplicitModeSelection:
"NEXTCLOUD_HOST": "http://localhost:8080",
"NEXTCLOUD_USERNAME": "admin", # Would auto-detect as single_user_basic
"NEXTCLOUD_PASSWORD": "password",
"MCP_DEPLOYMENT_MODE": "oauth_single_audience", # Explicit override
"MCP_DEPLOYMENT_MODE": "login_flow", # Explicit override
},
clear=True,
):
@@ -768,7 +787,7 @@ class TestExplicitModeSelection:
mode = detect_auth_mode(settings)
# Should use explicit mode, not auto-detected mode
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
def test_case_insensitive_mode_names(self):
"""Test MCP_DEPLOYMENT_MODE is case-insensitive."""
@@ -776,7 +795,7 @@ class TestExplicitModeSelection:
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": "OAUTH_SINGLE_AUDIENCE", # Uppercase
"MCP_DEPLOYMENT_MODE": "LOGIN_FLOW", # Uppercase
},
clear=True,
):
@@ -786,7 +805,7 @@ class TestExplicitModeSelection:
settings = get_settings()
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
def test_whitespace_in_mode_name_stripped(self):
"""Test whitespace in MCP_DEPLOYMENT_MODE is stripped."""
@@ -794,7 +813,7 @@ class TestExplicitModeSelection:
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": " oauth_single_audience ", # Whitespace
"MCP_DEPLOYMENT_MODE": " login_flow ", # Whitespace
},
clear=True,
):
@@ -804,4 +823,4 @@ class TestExplicitModeSelection:
settings = get_settings()
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
assert mode == AuthMode.LOGIN_FLOW
@@ -173,7 +173,7 @@ class TestStatusEndpointOidcConfig:
),
patch(
"nextcloud_mcp_server.api.management.detect_auth_mode",
return_value=AuthMode.OAUTH_SINGLE_AUDIENCE,
return_value=AuthMode.LOGIN_FLOW,
),
):
app = create_test_app()