From aa434b0f11c2486ae302a4cd8d6de1703d5ae2aa Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 11:40:13 -0400 Subject: [PATCH 1/5] [App Service] Fix #26603, #25662, #30322: `az webapp auth`: v2 auth migration improvements - `az webapp auth show` now auto-detects v2 auth and returns v2 settings when configured, falling back to v1 (#26603) - `az webapp auth update` routes to v2 API (authsettingsV2) when the app has v2 auth configured, with proper mapping of all params to v2 model structure (#25662, #26603) - v2-only parameters like `--require-https` automatically force the v2 code path for new auth setups (#25662) - Added `--require-https` parameter for v2 HTTP settings (#30322) - `--token-store`, `--aad-allowed-token-audiences`, and identity provider params now correctly map to v2 nested model structure (#30322) - Updated help text to reflect v2 support - Added 15 unit tests covering v2 detection, show/update routing, AAD, Facebook, action mapping, and v1 fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/_help.py | 11 +- .../cli/command_modules/appservice/_params.py | 4 + .../cli/command_modules/appservice/custom.py | 253 ++++++++++++++++- .../latest/test_webapp_commands_thru_mock.py | 263 +++++++++++++++++- 4 files changed, 520 insertions(+), 11 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index e0dad92e98c..1e86381c4d4 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1357,21 +1357,21 @@ helps['webapp auth'] = """ type: group -short-summary: Manage webapp authentication and authorization. To use v2 auth commands, run "az extension add --name authV2" to add the authV2 CLI extension. +short-summary: Manage webapp authentication and authorization. Supports both v1 and v2 auth configurations. Commands automatically detect and use v2 auth settings when configured. """ helps['webapp auth show'] = """ type: command -short-summary: Show the authentification settings for the webapp. +short-summary: Show the authentication settings for the webapp. Automatically returns v2 auth settings if the app is configured with v2 auth. examples: - - name: Show the authentification settings for the webapp. (autogenerated) + - name: Show the authentication settings for the webapp. text: az webapp auth show --name MyWebApp --resource-group MyResourceGroup crafted: true """ helps['webapp auth update'] = """ type: command -short-summary: Update the authentication settings for the webapp. +short-summary: Update the authentication settings for the webapp. Automatically uses v2 auth API when the app is configured with v2 auth or when v2-specific parameters are provided. examples: - name: Enable AAD by enabling authentication and setting AAD-associated parameters. Default provider is set to AAD. Must have created a AAD service principal beforehand. text: > @@ -1385,6 +1385,9 @@ az webapp auth update -g myResourceGroup -n myUniqueApp --action AllowAnonymous \\ --facebook-app-id my_fb_id --facebook-app-secret my_fb_secret \\ --facebook-oauth-scopes public_profile email + - name: Enable HTTPS requirement for authentication requests (v2 auth) + text: > + az webapp auth update -g myResourceGroup -n myUniqueApp --require-https true """ helps['webapp browse'] = """ diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index bd4dc0b7ea5..12921e17a24 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -917,6 +917,10 @@ def load_arguments(self, _): c.argument('microsoft_account_client_secret', arg_group='Microsoft', help='AAD V2 Application client secret') c.argument('microsoft_account_oauth_scopes', nargs='+', help="One or more Microsoft authentification scopes (comma-delimited).", arg_group='Microsoft') + c.argument('require_https', options_list=['--require-https'], + arg_type=get_three_state_flag(return_label=True), arg_group='Auth V2', + help='Require HTTPS for authentication requests. When using v2 auth, ' + 'configures the HTTP settings to require HTTPS.') with self.argument_context('webapp hybrid-connection') as c: c.argument('name', arg_type=webapp_name_arg_type, id_part=None) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 386ba088608..5f4e6fb5de8 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -2686,7 +2686,31 @@ def setter(webapp): return webapp.identity +def _get_auth_settings_v2(cmd, resource_group_name, name, slot=None): + return _generic_site_operation(cmd.cli_ctx, resource_group_name, name, + 'get_auth_settings_v2', slot) + + +def _is_auth_v2_app(auth_settings_v2): + """Check if the app has v2 auth configured by inspecting the v2 settings object.""" + if auth_settings_v2 is None: + return False + platform = getattr(auth_settings_v2, 'platform', None) + if platform and getattr(platform, 'enabled', None) is not None: + return True + identity_providers = getattr(auth_settings_v2, 'identity_providers', None) + if identity_providers and getattr(identity_providers, 'azure_active_directory', None): + return True + return False + + def get_auth_settings(cmd, resource_group_name, name, slot=None): + try: + auth_settings_v2 = _get_auth_settings_v2(cmd, resource_group_name, name, slot) + if _is_auth_v2_app(auth_settings_v2): + return auth_settings_v2 + except Exception: # pylint: disable=broad-except + pass return _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get_auth_settings', slot) @@ -2710,6 +2734,177 @@ def is_auth_runtime_version_valid(runtime_version=None): return True +def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, + enabled=None, action=None, client_id=None, + token_store_enabled=None, runtime_version=None, + token_refresh_extension_hours=None, + allowed_external_redirect_urls=None, client_secret=None, + client_secret_certificate_thumbprint=None, + allowed_audiences=None, issuer=None, + facebook_app_id=None, facebook_app_secret=None, + facebook_oauth_scopes=None, + twitter_consumer_key=None, twitter_consumer_secret=None, + google_client_id=None, google_client_secret=None, + google_oauth_scopes=None, + microsoft_account_client_id=None, + microsoft_account_client_secret=None, + microsoft_account_oauth_scopes=None, + require_https=None, slot=None): + """Apply parameter updates to a SiteAuthSettingsV2 object and persist it.""" + from azure.mgmt.web.models import (AuthPlatform, GlobalValidation, IdentityProviders, + Login, HttpSettings, TokenStore, + AzureActiveDirectory, AzureActiveDirectoryRegistration, + AzureActiveDirectoryValidation, + Facebook, AppRegistration, LoginScopes, + Google, ClientRegistration, + Twitter, TwitterRegistration) + + # -- platform -- + if auth_settings_v2.platform is None: + auth_settings_v2.platform = AuthPlatform() + if enabled is not None: + auth_settings_v2.platform.enabled = enabled == 'true' + if runtime_version is not None: + auth_settings_v2.platform.runtime_version = runtime_version + + # -- global_validation -- + if action is not None: + if auth_settings_v2.global_validation is None: + auth_settings_v2.global_validation = GlobalValidation() + if action == 'AllowAnonymous': + auth_settings_v2.global_validation.unauthenticated_client_action = 'AllowAnonymous' + else: + auth_settings_v2.global_validation.unauthenticated_client_action = 'RedirectToLoginPage' + provider_map = { + 'LoginWithAzureActiveDirectory': 'azureactivedirectory', + 'LoginWithFacebook': 'facebook', + 'LoginWithGoogle': 'google', + 'LoginWithMicrosoftAccount': 'microsoftaccount', + 'LoginWithTwitter': 'twitter', + } + auth_settings_v2.global_validation.redirect_to_provider = provider_map.get(action, action) + + # -- login / token_store -- + if auth_settings_v2.login is None: + auth_settings_v2.login = Login() + if token_store_enabled is not None or token_refresh_extension_hours is not None: + if auth_settings_v2.login.token_store is None: + auth_settings_v2.login.token_store = TokenStore() + if token_store_enabled is not None: + auth_settings_v2.login.token_store.enabled = token_store_enabled == 'true' + if token_refresh_extension_hours is not None: + auth_settings_v2.login.token_store.token_refresh_extension_hours = token_refresh_extension_hours + if allowed_external_redirect_urls is not None: + auth_settings_v2.login.allowed_external_redirect_urls = allowed_external_redirect_urls + + # -- http_settings -- + if require_https is not None: + if auth_settings_v2.http_settings is None: + auth_settings_v2.http_settings = HttpSettings() + auth_settings_v2.http_settings.require_https = require_https == 'true' + + # -- identity_providers -- + if auth_settings_v2.identity_providers is None: + auth_settings_v2.identity_providers = IdentityProviders() + ip = auth_settings_v2.identity_providers + + # AAD + if any(v is not None for v in [client_id, client_secret, client_secret_certificate_thumbprint, + allowed_audiences, issuer]): + if ip.azure_active_directory is None: + ip.azure_active_directory = AzureActiveDirectory(enabled=True) + else: + ip.azure_active_directory.enabled = True + aad = ip.azure_active_directory + if aad.registration is None: + aad.registration = AzureActiveDirectoryRegistration() + if client_id is not None: + aad.registration.client_id = client_id + if client_secret is not None: + aad.registration.client_secret_setting_name = client_secret + if client_secret_certificate_thumbprint is not None: + aad.registration.client_secret_certificate_thumbprint = client_secret_certificate_thumbprint + if issuer is not None: + aad.registration.open_id_issuer = issuer + if allowed_audiences is not None: + if aad.validation is None: + aad.validation = AzureActiveDirectoryValidation() + aad.validation.allowed_audiences = allowed_audiences + + # Facebook + if any(v is not None for v in [facebook_app_id, facebook_app_secret, facebook_oauth_scopes]): + if ip.facebook is None: + ip.facebook = Facebook(enabled=True) + else: + ip.facebook.enabled = True + if facebook_app_id is not None or facebook_app_secret is not None: + if ip.facebook.registration is None: + ip.facebook.registration = AppRegistration() + if facebook_app_id is not None: + ip.facebook.registration.app_id = facebook_app_id + if facebook_app_secret is not None: + ip.facebook.registration.app_secret_setting_name = facebook_app_secret + if facebook_oauth_scopes is not None: + if ip.facebook.login is None: + ip.facebook.login = LoginScopes() + ip.facebook.login.scopes = facebook_oauth_scopes + + # Google + if any(v is not None for v in [google_client_id, google_client_secret, google_oauth_scopes]): + if ip.google is None: + ip.google = Google(enabled=True) + else: + ip.google.enabled = True + if google_client_id is not None or google_client_secret is not None: + if ip.google.registration is None: + ip.google.registration = ClientRegistration() + if google_client_id is not None: + ip.google.registration.client_id = google_client_id + if google_client_secret is not None: + ip.google.registration.client_secret_setting_name = google_client_secret + if google_oauth_scopes is not None: + if ip.google.login is None: + ip.google.login = LoginScopes() + ip.google.login.scopes = google_oauth_scopes + + # Twitter + if any(v is not None for v in [twitter_consumer_key, twitter_consumer_secret]): + if ip.twitter is None: + ip.twitter = Twitter(enabled=True) + else: + ip.twitter.enabled = True + if ip.twitter.registration is None: + ip.twitter.registration = TwitterRegistration() + if twitter_consumer_key is not None: + ip.twitter.registration.consumer_key = twitter_consumer_key + if twitter_consumer_secret is not None: + ip.twitter.registration.consumer_secret_setting_name = twitter_consumer_secret + + # Microsoft Account (legacy) + if any(v is not None for v in [microsoft_account_client_id, microsoft_account_client_secret, + microsoft_account_oauth_scopes]): + if ip.legacy_microsoft_account is None: + from azure.mgmt.web.models import LegacyMicrosoftAccount + ip.legacy_microsoft_account = LegacyMicrosoftAccount(enabled=True) + else: + ip.legacy_microsoft_account.enabled = True + if microsoft_account_client_id is not None or microsoft_account_client_secret is not None: + if ip.legacy_microsoft_account.registration is None: + ip.legacy_microsoft_account.registration = ClientRegistration() + if microsoft_account_client_id is not None: + ip.legacy_microsoft_account.registration.client_id = microsoft_account_client_id + if microsoft_account_client_secret is not None: + ip.legacy_microsoft_account.registration.client_secret_setting_name = \ + microsoft_account_client_secret + if microsoft_account_oauth_scopes is not None: + if ip.legacy_microsoft_account.login is None: + ip.legacy_microsoft_account.login = LoginScopes() + ip.legacy_microsoft_account.login.scopes = microsoft_account_oauth_scopes + + return _generic_site_operation(cmd.cli_ctx, resource_group_name, name, + 'update_auth_settings_v2', slot, auth_settings_v2) + + def update_auth_settings(cmd, resource_group_name, name, enabled=None, action=None, # pylint: disable=unused-argument client_id=None, token_store_enabled=None, runtime_version=None, # pylint: disable=unused-argument token_refresh_extension_hours=None, # pylint: disable=unused-argument @@ -2721,17 +2916,58 @@ def update_auth_settings(cmd, resource_group_name, name, enabled=None, action=No google_client_id=None, google_client_secret=None, # pylint: disable=unused-argument google_oauth_scopes=None, microsoft_account_client_id=None, # pylint: disable=unused-argument microsoft_account_client_secret=None, # pylint: disable=unused-argument - microsoft_account_oauth_scopes=None, slot=None): # pylint: disable=unused-argument - auth_settings = get_auth_settings(cmd, resource_group_name, name, slot) + microsoft_account_oauth_scopes=None, # pylint: disable=unused-argument + require_https=None, slot=None): # pylint: disable=unused-argument + # validate runtime version + if not is_auth_runtime_version_valid(runtime_version): + raise InvalidArgumentValueError('Usage Error: --runtime-version set to invalid value') + + # Determine whether to use v2 or v1 auth API + use_v2 = False + auth_settings_v2 = None + try: + auth_settings_v2 = _get_auth_settings_v2(cmd, resource_group_name, name, slot) + if _is_auth_v2_app(auth_settings_v2): + use_v2 = True + except Exception: # pylint: disable=broad-except + pass + + # v2-only params force v2 path for new auth setups + if require_https is not None: + use_v2 = True + + if use_v2: + if auth_settings_v2 is None: + from azure.mgmt.web.models import SiteAuthSettingsV2 + auth_settings_v2 = SiteAuthSettingsV2() + return _update_auth_settings_v2( + cmd, resource_group_name, name, auth_settings_v2, + enabled=enabled, action=action, client_id=client_id, + token_store_enabled=token_store_enabled, runtime_version=runtime_version, + token_refresh_extension_hours=token_refresh_extension_hours, + allowed_external_redirect_urls=allowed_external_redirect_urls, + client_secret=client_secret, + client_secret_certificate_thumbprint=client_secret_certificate_thumbprint, + allowed_audiences=allowed_audiences, issuer=issuer, + facebook_app_id=facebook_app_id, facebook_app_secret=facebook_app_secret, + facebook_oauth_scopes=facebook_oauth_scopes, + twitter_consumer_key=twitter_consumer_key, + twitter_consumer_secret=twitter_consumer_secret, + google_client_id=google_client_id, google_client_secret=google_client_secret, + google_oauth_scopes=google_oauth_scopes, + microsoft_account_client_id=microsoft_account_client_id, + microsoft_account_client_secret=microsoft_account_client_secret, + microsoft_account_oauth_scopes=microsoft_account_oauth_scopes, + require_https=require_https, slot=slot) + + # v1 path + auth_settings = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get_auth_settings', slot) UnauthenticatedClientAction = cmd.get_models('UnauthenticatedClientAction') if action == 'AllowAnonymous': auth_settings.unauthenticated_client_action = UnauthenticatedClientAction.allow_anonymous elif action: auth_settings.unauthenticated_client_action = UnauthenticatedClientAction.redirect_to_login_page auth_settings.default_provider = AUTH_TYPES[action] - # validate runtime version - if not is_auth_runtime_version_valid(runtime_version): - raise InvalidArgumentValueError('Usage Error: --runtime-version set to invalid value') import inspect frame = inspect.currentframe() @@ -2740,7 +2976,12 @@ def update_auth_settings(cmd, resource_group_name, name, enabled=None, action=No # and no simple functional replacement for this deprecating method for 3.5 args, _, _, values = inspect.getargvalues(frame) # pylint: disable=deprecated-method - for arg in args[2:]: + # Skip v2-only params and non-settable args when applying to v1 model + v2_only_args = {'require_https'} + skip_args = {'cmd', 'resource_group_name', 'name', 'action', 'slot'} | v2_only_args + for arg in args: + if arg in skip_args: + continue if values.get(arg, None): setattr(auth_settings, arg, values[arg] if arg not in bool_flags else values[arg] == 'true') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 853eadc1edd..0419daef1da 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -33,7 +33,12 @@ add_github_actions, update_app_settings, update_application_settings_polling, - update_webapp) + update_webapp, + get_auth_settings, + update_auth_settings, + _is_auth_v2_app, + _get_auth_settings_v2, + _update_auth_settings_v2) # pylint: disable=line-too-long from azure.cli.core.profiles import ResourceType @@ -639,6 +644,262 @@ def test_update_webapp_platform_release_channel_latest(self): self.assertEqual(result.additional_properties["properties"]["platformReleaseChannel"], "Latest") +class TestWebappAuthV2Mocked(unittest.TestCase): + """Tests for v1/v2 auth migration logic.""" + + def test_is_auth_v2_app_none(self): + self.assertFalse(_is_auth_v2_app(None)) + + def test_is_auth_v2_app_empty(self): + from azure.mgmt.web.models import SiteAuthSettingsV2 + self.assertFalse(_is_auth_v2_app(SiteAuthSettingsV2())) + + def test_is_auth_v2_app_with_platform_enabled(self): + from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform + settings = SiteAuthSettingsV2(platform=AuthPlatform(enabled=True)) + self.assertTrue(_is_auth_v2_app(settings)) + + def test_is_auth_v2_app_with_platform_disabled(self): + from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform + settings = SiteAuthSettingsV2(platform=AuthPlatform(enabled=False)) + self.assertTrue(_is_auth_v2_app(settings)) + + def test_is_auth_v2_app_with_identity_providers(self): + from azure.mgmt.web.models import (SiteAuthSettingsV2, IdentityProviders, + AzureActiveDirectory) + settings = SiteAuthSettingsV2( + identity_providers=IdentityProviders( + azure_active_directory=AzureActiveDirectory(enabled=True))) + self.assertTrue(_is_auth_v2_app(settings)) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_get_auth_settings_returns_v2_when_configured(self, mock_site_op): + from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform + v2_settings = SiteAuthSettingsV2(platform=AuthPlatform(enabled=True)) + mock_site_op.return_value = v2_settings + + cmd = _get_test_cmd() + result = get_auth_settings(cmd, 'rg', 'myapp') + + self.assertIsInstance(result, SiteAuthSettingsV2) + mock_site_op.assert_called_once_with(cmd.cli_ctx, 'rg', 'myapp', + 'get_auth_settings_v2', None) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_get_auth_settings_falls_back_to_v1(self, mock_site_op): + from azure.mgmt.web.models import SiteAuthSettingsV2 + + v2_settings = SiteAuthSettingsV2() # empty = not v2 + v1_settings = mock.MagicMock() + v1_settings.enabled = True + + def side_effect(cli_ctx, rg, name, op, slot=None): + if op == 'get_auth_settings_v2': + return v2_settings + return v1_settings + + mock_site_op.side_effect = side_effect + + cmd = _get_test_cmd() + result = get_auth_settings(cmd, 'rg', 'myapp') + + self.assertEqual(result, v1_settings) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_get_auth_settings_v2_exception_falls_back_to_v1(self, mock_site_op): + v1_settings = mock.MagicMock() + v1_settings.enabled = False + + def side_effect(cli_ctx, rg, name, op, slot=None): + if op == 'get_auth_settings_v2': + raise HttpResponseError(message="Not found") + return v1_settings + + mock_site_op.side_effect = side_effect + + cmd = _get_test_cmd() + result = get_auth_settings(cmd, 'rg', 'myapp') + + self.assertEqual(result, v1_settings) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_update_auth_settings_uses_v2_when_configured(self, mock_site_op): + from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform + v2_settings = SiteAuthSettingsV2(platform=AuthPlatform(enabled=True)) + updated_v2 = SiteAuthSettingsV2(platform=AuthPlatform(enabled=True)) + + def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): + if op == 'get_auth_settings_v2': + return v2_settings + if op == 'update_auth_settings_v2': + return updated_v2 + return mock.MagicMock() + + mock_site_op.side_effect = side_effect + + cmd = _get_test_cmd() + result = update_auth_settings(cmd, 'rg', 'myapp', enabled='true', + client_id='test-client-id') + + self.assertEqual(result, updated_v2) + # Verify update_auth_settings_v2 was called + calls = [c for c in mock_site_op.call_args_list if c[0][3] == 'update_auth_settings_v2'] + self.assertEqual(len(calls), 1) + # Verify the v2 settings were modified + sent_settings = calls[0][1].get('extra_parameter') or calls[0][0][4] if len(calls[0][0]) > 4 else None + # The auth settings object should have been passed as extra_parameter + update_call_args = calls[0] + self.assertIn('update_auth_settings_v2', update_call_args[0]) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_update_auth_settings_require_https_forces_v2(self, mock_site_op): + from azure.mgmt.web.models import SiteAuthSettingsV2 + v2_settings = SiteAuthSettingsV2() # empty = not v2 configured yet + updated_v2 = SiteAuthSettingsV2() + + def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): + if op == 'get_auth_settings_v2': + return v2_settings + if op == 'update_auth_settings_v2': + return extra_parameter # return what was sent + return mock.MagicMock() + + mock_site_op.side_effect = side_effect + + cmd = _get_test_cmd() + result = update_auth_settings(cmd, 'rg', 'myapp', require_https='true') + + # Should have used v2 path due to --require-https + self.assertIsNotNone(result.http_settings) + self.assertTrue(result.http_settings.require_https) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_update_auth_settings_v1_fallback(self, mock_site_op): + from azure.mgmt.web.models import SiteAuthSettingsV2 + v2_settings = SiteAuthSettingsV2() # empty = not v2 + v1_settings = mock.MagicMock() + v1_settings.enabled = False + + def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): + if op == 'get_auth_settings_v2': + return v2_settings + if op == 'get_auth_settings': + return v1_settings + if op == 'update_auth_settings': + return extra_parameter + return mock.MagicMock() + + mock_site_op.side_effect = side_effect + + cmd = _get_test_cmd() + result = update_auth_settings(cmd, 'rg', 'myapp', enabled='true', + facebook_app_id='fb-id') + + # Should have used v1 path + self.assertTrue(result.enabled) + self.assertEqual(result.facebook_app_id, 'fb-id') + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_update_auth_v2_aad_settings(self, mock_site_op): + from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform + v2_settings = SiteAuthSettingsV2(platform=AuthPlatform(enabled=True)) + + def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): + if op == 'get_auth_settings_v2': + return v2_settings + if op == 'update_auth_settings_v2': + return extra_parameter + return mock.MagicMock() + + mock_site_op.side_effect = side_effect + + cmd = _get_test_cmd() + result = update_auth_settings( + cmd, 'rg', 'myapp', + client_id='my-client-id', + client_secret='my-secret', + allowed_audiences=['https://myapp.azurewebsites.net'], + issuer='https://sts.windows.net/tenant-id/', + token_store_enabled='true') + + # Verify AAD settings in v2 structure + aad = result.identity_providers.azure_active_directory + self.assertTrue(aad.enabled) + self.assertEqual(aad.registration.client_id, 'my-client-id') + self.assertEqual(aad.registration.client_secret_setting_name, 'my-secret') + self.assertEqual(aad.registration.open_id_issuer, 'https://sts.windows.net/tenant-id/') + self.assertEqual(aad.validation.allowed_audiences, ['https://myapp.azurewebsites.net']) + # Verify token store + self.assertTrue(result.login.token_store.enabled) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_update_auth_v2_facebook_settings(self, mock_site_op): + from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform + v2_settings = SiteAuthSettingsV2(platform=AuthPlatform(enabled=True)) + + def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): + if op == 'get_auth_settings_v2': + return v2_settings + if op == 'update_auth_settings_v2': + return extra_parameter + return mock.MagicMock() + + mock_site_op.side_effect = side_effect + + cmd = _get_test_cmd() + result = update_auth_settings( + cmd, 'rg', 'myapp', + facebook_app_id='fb-app-id', + facebook_app_secret='fb-secret', + facebook_oauth_scopes=['public_profile', 'email']) + + fb = result.identity_providers.facebook + self.assertTrue(fb.enabled) + self.assertEqual(fb.registration.app_id, 'fb-app-id') + self.assertEqual(fb.registration.app_secret_setting_name, 'fb-secret') + self.assertEqual(fb.login.scopes, ['public_profile', 'email']) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_update_auth_v2_action_allow_anonymous(self, mock_site_op): + from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform + v2_settings = SiteAuthSettingsV2(platform=AuthPlatform(enabled=True)) + + def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): + if op == 'get_auth_settings_v2': + return v2_settings + if op == 'update_auth_settings_v2': + return extra_parameter + return mock.MagicMock() + + mock_site_op.side_effect = side_effect + + cmd = _get_test_cmd() + result = update_auth_settings(cmd, 'rg', 'myapp', action='AllowAnonymous') + + self.assertEqual(result.global_validation.unauthenticated_client_action, 'AllowAnonymous') + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_update_auth_v2_action_login_with_aad(self, mock_site_op): + from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform + v2_settings = SiteAuthSettingsV2(platform=AuthPlatform(enabled=True)) + + def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): + if op == 'get_auth_settings_v2': + return v2_settings + if op == 'update_auth_settings_v2': + return extra_parameter + return mock.MagicMock() + + mock_site_op.side_effect = side_effect + + cmd = _get_test_cmd() + result = update_auth_settings(cmd, 'rg', 'myapp', + action='LoginWithAzureActiveDirectory') + + self.assertEqual(result.global_validation.unauthenticated_client_action, 'RedirectToLoginPage') + self.assertEqual(result.global_validation.redirect_to_provider, 'azureactivedirectory') + + class FakedResponse: # pylint: disable=too-few-public-methods def __init__(self, status_code): self.status_code = status_code From 9d9c4af7ce724e86e311c5918d1cc27161e42627 Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Wed, 25 Mar 2026 21:35:53 -0400 Subject: [PATCH 2/5] [App Service] Fix #30021: Detect SP auth in deployment source config When az webapp deployment source config --github-action is called with Service Principal authentication, the Azure API returns 404 trying to look up a publishing user. This change adds client-side detection to provide a clear error message directing users to az webapp deployment github-actions add, which supports SP auth. Fixes #30021 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/custom.py | 18 +++ .../latest/test_webapp_commands_thru_mock.py | 110 +++++++++++++++++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 5f4e6fb5de8..df72d5ad545 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -117,6 +117,17 @@ # Please maintain compatibility in both interfaces and functionalities" +def _is_service_principal_auth(cli_ctx): + """Check if current authentication is via Service Principal.""" + from azure.cli.core._profile import Profile + from azure.cli.core.commands.client_factory import get_subscription_id + profile = Profile(cli_ctx=cli_ctx) + subscription_id = get_subscription_id(cli_ctx) + account = profile.get_subscription(subscription=subscription_id) + # Service principals have user.type == 'servicePrincipal' + return account.get('user', {}).get('type') == 'servicePrincipal' + + def create_webapp(cmd, resource_group_name, name, plan, runtime=None, startup_file=None, # pylint: disable=too-many-statements,too-many-branches deployment_container_image_name=None, deployment_source_url=None, deployment_source_branch='master', deployment_local_git=None, sitecontainers_app=None, @@ -4238,6 +4249,13 @@ def config_source_control(cmd, resource_group_name, name, repo_url, repository_t client = web_client_factory(cmd.cli_ctx) location = _get_location_from_webapp(client, resource_group_name, name) + # Check for Service Principal + GitHub Actions incompatibility + if github_action and _is_service_principal_auth(cmd.cli_ctx): + raise ValidationError( + "GitHub Actions deployment cannot be configured with Service Principal authentication. " + "Use 'az webapp deployment github-actions add' instead, which supports Service Principal workflows." + ) + from azure.mgmt.web.models import SiteSourceControl, SourceControl if git_token: sc = SourceControl(location=location, source_control_name='GitHub', token=git_token) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 0419daef1da..02a4fe5c924 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -38,7 +38,8 @@ update_auth_settings, _is_auth_v2_app, _get_auth_settings_v2, - _update_auth_settings_v2) + _update_auth_settings_v2, + config_source_control) # pylint: disable=line-too-long from azure.cli.core.profiles import ResourceType @@ -899,6 +900,113 @@ def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): self.assertEqual(result.global_validation.unauthenticated_client_action, 'RedirectToLoginPage') self.assertEqual(result.global_validation.redirect_to_provider, 'azureactivedirectory') +class TestServicePrincipalDeploymentSource(unittest.TestCase): + """Tests for Service Principal authentication detection in deployment source config""" + + @mock.patch('azure.cli.command_modules.appservice.custom._is_service_principal_auth') + @mock.patch('azure.cli.command_modules.appservice.custom._get_location_from_webapp') + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + def test_config_source_control_with_sp_and_github_action_raises_error( + self, web_client_factory_mock, get_location_mock, is_sp_auth_mock + ): + """Test that SP auth + --github-action raises ValidationError""" + from azure.cli.core.azclierror import ValidationError + + # Setup mocks + is_sp_auth_mock.return_value = True + get_location_mock.return_value = 'eastus' + + cmd = _get_test_cmd() + + # Execute and assert + with self.assertRaises(ValidationError) as context: + config_source_control( + cmd, + resource_group_name='test-rg', + name='test-app', + repo_url='https://github.com/test/repo', + github_action=True + ) + + self.assertIn('Service Principal authentication', str(context.exception)) + self.assertIn('az webapp deployment github-actions add', str(context.exception)) + + @mock.patch('azure.cli.command_modules.appservice.custom._is_service_principal_auth') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom._get_location_from_webapp') + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + def test_config_source_control_with_user_and_github_action_succeeds( + self, web_client_factory_mock, get_location_mock, generic_site_op_mock, is_sp_auth_mock + ): + """Test that user auth + --github-action passes through (no error raised)""" + + # Setup mocks + is_sp_auth_mock.return_value = False # User authentication + get_location_mock.return_value = 'eastus' + + # Mock the site operation to return a mock response + mock_poller = mock.Mock() + mock_poller.done.return_value = True + mock_response = mock.Mock() + mock_response.git_hub_action_configuration = None + mock_poller.result.return_value = mock_response + generic_site_op_mock.return_value = mock_poller + + cmd = _get_test_cmd() + + # Execute - should not raise an error + config_source_control( + cmd, + resource_group_name='test-rg', + name='test-app', + repo_url='https://github.com/test/repo', + github_action=True + ) + + # Assert _generic_site_operation was called with correct SiteSourceControl + generic_site_op_mock.assert_called_once() + call_args = generic_site_op_mock.call_args + source_control_arg = call_args[0][5] + self.assertTrue(source_control_arg.is_git_hub_action) + + @mock.patch('azure.cli.command_modules.appservice.custom._is_service_principal_auth') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom._get_location_from_webapp') + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + def test_config_source_control_with_sp_without_github_action_succeeds( + self, web_client_factory_mock, get_location_mock, generic_site_op_mock, is_sp_auth_mock + ): + """Test that SP auth without --github-action passes through (no error raised)""" + + # Setup mocks + is_sp_auth_mock.return_value = True # Service Principal authentication + get_location_mock.return_value = 'eastus' + + # Mock the site operation to return a mock response + mock_poller = mock.Mock() + mock_poller.done.return_value = True + mock_response = mock.Mock() + mock_response.git_hub_action_configuration = None + mock_poller.result.return_value = mock_response + generic_site_op_mock.return_value = mock_poller + + cmd = _get_test_cmd() + + # Execute - should not raise an error when github_action is None/False + config_source_control( + cmd, + resource_group_name='test-rg', + name='test-app', + repo_url='https://github.com/test/repo', + github_action=None # Not using GitHub Actions + ) + + # Assert _generic_site_operation was called with correct SiteSourceControl + generic_site_op_mock.assert_called_once() + call_args = generic_site_op_mock.call_args + source_control_arg = call_args[0][5] + self.assertFalse(source_control_arg.is_git_hub_action) + class FakedResponse: # pylint: disable=too-few-public-methods def __init__(self, status_code): From 6751003df79131be65cebfc046a9dce70cfedd55 Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 15:27:09 -0400 Subject: [PATCH 3/5] Address PR review: broaden v2 detection, narrow exceptions, fix secret handling - Fix 1: Broaden _is_auth_v2_app to detect Facebook, Google, Twitter, Microsoft identity providers and v2-specific sections (http_settings, login, global_validation) - Fix 2: In get_auth_settings, catch only HttpResponseError instead of bare except, re-raise non-404 errors - Fix 3: Same narrow exception handling in update_auth_settings v2 probe - Fix 4: Wrap _is_service_principal_auth in try/except to handle profile lookup failures gracefully (return False) - Fix 5: Store secret values in app settings and reference by name in client_secret_setting_name fields (AAD, Facebook, Google, Twitter, Microsoft Account) - Fix 6: Add proper assertions on sent_settings in test_update_auth_settings_uses_v2_when_configured - Fix flake8 continuation line indentation (E127/E128) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/custom.py | 109 ++++++++++++++---- .../latest/test_webapp_commands_thru_mock.py | 42 +++++-- 2 files changed, 120 insertions(+), 31 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index df72d5ad545..ecd4b958a18 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -118,12 +118,25 @@ def _is_service_principal_auth(cli_ctx): - """Check if current authentication is via Service Principal.""" + """Check if current authentication is via Service Principal. + + This helper performs profile/subscription lookups which can fail in cases such as + no subscription being selected or token/cache issues. In those situations, we + conservatively return False instead of raising, to avoid blocking unrelated + command flows (e.g. deployment source configuration) with profile errors. + """ from azure.cli.core._profile import Profile from azure.cli.core.commands.client_factory import get_subscription_id - profile = Profile(cli_ctx=cli_ctx) - subscription_id = get_subscription_id(cli_ctx) - account = profile.get_subscription(subscription=subscription_id) + try: + profile = Profile(cli_ctx=cli_ctx) + subscription_id = get_subscription_id(cli_ctx) + if not subscription_id: + logger.debug("Unable to determine authentication type: no active subscription ID.") + return False + account = profile.get_subscription(subscription=subscription_id) + except Exception as ex: # pylint: disable=broad-exception-caught + logger.debug("Failed to determine authentication type from profile: %s", ex) + return False # Service principals have user.type == 'servicePrincipal' return account.get('user', {}).get('type') == 'servicePrincipal' @@ -2706,11 +2719,29 @@ def _is_auth_v2_app(auth_settings_v2): """Check if the app has v2 auth configured by inspecting the v2 settings object.""" if auth_settings_v2 is None: return False + # If the platform block is present and explicitly configured, this is a v2 app. platform = getattr(auth_settings_v2, 'platform', None) if platform and getattr(platform, 'enabled', None) is not None: return True + # Check for any configured identity provider, not just Azure Active Directory. identity_providers = getattr(auth_settings_v2, 'identity_providers', None) - if identity_providers and getattr(identity_providers, 'azure_active_directory', None): + if identity_providers: + if getattr(identity_providers, 'azure_active_directory', None): + return True + if getattr(identity_providers, 'facebook', None): + return True + if getattr(identity_providers, 'google', None): + return True + if getattr(identity_providers, 'twitter', None): + return True + if getattr(identity_providers, 'microsoft', None): + return True + # Presence of other v2-specific configuration sections also indicates v2 auth. + if getattr(auth_settings_v2, 'http_settings', None) is not None: + return True + if getattr(auth_settings_v2, 'login', None) is not None: + return True + if getattr(auth_settings_v2, 'global_validation', None) is not None: return True return False @@ -2720,8 +2751,11 @@ def get_auth_settings(cmd, resource_group_name, name, slot=None): auth_settings_v2 = _get_auth_settings_v2(cmd, resource_group_name, name, slot) if _is_auth_v2_app(auth_settings_v2): return auth_settings_v2 - except Exception: # pylint: disable=broad-except - pass + except HttpResponseError as ex: + status_code = getattr(ex, 'status_code', None) + if status_code not in (404, None): + logger.debug("Failed to get auth settings v2 for '%s': %s", name, ex, exc_info=True) + raise return _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get_auth_settings', slot) @@ -2762,13 +2796,14 @@ def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, microsoft_account_oauth_scopes=None, require_https=None, slot=None): """Apply parameter updates to a SiteAuthSettingsV2 object and persist it.""" - from azure.mgmt.web.models import (AuthPlatform, GlobalValidation, IdentityProviders, - Login, HttpSettings, TokenStore, - AzureActiveDirectory, AzureActiveDirectoryRegistration, - AzureActiveDirectoryValidation, - Facebook, AppRegistration, LoginScopes, - Google, ClientRegistration, - Twitter, TwitterRegistration) + from azure.mgmt.web.models import ( + AuthPlatform, GlobalValidation, IdentityProviders, + Login, HttpSettings, TokenStore, + AzureActiveDirectory, AzureActiveDirectoryRegistration, + AzureActiveDirectoryValidation, + Facebook, AppRegistration, LoginScopes, + Google, ClientRegistration, + Twitter, TwitterRegistration) # -- platform -- if auth_settings_v2.platform is None: @@ -2819,9 +2854,14 @@ def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, auth_settings_v2.identity_providers = IdentityProviders() ip = auth_settings_v2.identity_providers + # Collect secrets that need to be stored as app settings. + # The v2 auth model *_secret_setting_name fields expect the name of an app setting, + # not the secret value itself. + secrets_to_store = {} + # AAD if any(v is not None for v in [client_id, client_secret, client_secret_certificate_thumbprint, - allowed_audiences, issuer]): + allowed_audiences, issuer]): if ip.azure_active_directory is None: ip.azure_active_directory = AzureActiveDirectory(enabled=True) else: @@ -2832,7 +2872,9 @@ def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, if client_id is not None: aad.registration.client_id = client_id if client_secret is not None: - aad.registration.client_secret_setting_name = client_secret + setting_name = 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET' + secrets_to_store[setting_name] = client_secret + aad.registration.client_secret_setting_name = setting_name if client_secret_certificate_thumbprint is not None: aad.registration.client_secret_certificate_thumbprint = client_secret_certificate_thumbprint if issuer is not None: @@ -2854,7 +2896,9 @@ def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, if facebook_app_id is not None: ip.facebook.registration.app_id = facebook_app_id if facebook_app_secret is not None: - ip.facebook.registration.app_secret_setting_name = facebook_app_secret + setting_name = 'FACEBOOK_PROVIDER_AUTHENTICATION_SECRET' + secrets_to_store[setting_name] = facebook_app_secret + ip.facebook.registration.app_secret_setting_name = setting_name if facebook_oauth_scopes is not None: if ip.facebook.login is None: ip.facebook.login = LoginScopes() @@ -2872,7 +2916,9 @@ def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, if google_client_id is not None: ip.google.registration.client_id = google_client_id if google_client_secret is not None: - ip.google.registration.client_secret_setting_name = google_client_secret + setting_name = 'GOOGLE_PROVIDER_AUTHENTICATION_SECRET' + secrets_to_store[setting_name] = google_client_secret + ip.google.registration.client_secret_setting_name = setting_name if google_oauth_scopes is not None: if ip.google.login is None: ip.google.login = LoginScopes() @@ -2889,11 +2935,13 @@ def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, if twitter_consumer_key is not None: ip.twitter.registration.consumer_key = twitter_consumer_key if twitter_consumer_secret is not None: - ip.twitter.registration.consumer_secret_setting_name = twitter_consumer_secret + setting_name = 'TWITTER_PROVIDER_AUTHENTICATION_SECRET' + secrets_to_store[setting_name] = twitter_consumer_secret + ip.twitter.registration.consumer_secret_setting_name = setting_name # Microsoft Account (legacy) if any(v is not None for v in [microsoft_account_client_id, microsoft_account_client_secret, - microsoft_account_oauth_scopes]): + microsoft_account_oauth_scopes]): if ip.legacy_microsoft_account is None: from azure.mgmt.web.models import LegacyMicrosoftAccount ip.legacy_microsoft_account = LegacyMicrosoftAccount(enabled=True) @@ -2905,13 +2953,23 @@ def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, if microsoft_account_client_id is not None: ip.legacy_microsoft_account.registration.client_id = microsoft_account_client_id if microsoft_account_client_secret is not None: - ip.legacy_microsoft_account.registration.client_secret_setting_name = \ - microsoft_account_client_secret + setting_name = 'MICROSOFTACCOUNT_PROVIDER_AUTHENTICATION_SECRET' + secrets_to_store[setting_name] = microsoft_account_client_secret + ip.legacy_microsoft_account.registration.client_secret_setting_name = setting_name if microsoft_account_oauth_scopes is not None: if ip.legacy_microsoft_account.login is None: ip.legacy_microsoft_account.login = LoginScopes() ip.legacy_microsoft_account.login.scopes = microsoft_account_oauth_scopes + # Store all collected secrets into app settings in a single API call. + if secrets_to_store: + app_settings = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, + 'list_application_settings', slot) + for setting_key, secret_value in secrets_to_store.items(): + app_settings.properties[setting_key] = secret_value + _generic_site_operation(cmd.cli_ctx, resource_group_name, name, + 'update_application_settings', slot, app_settings) + return _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'update_auth_settings_v2', slot, auth_settings_v2) @@ -2940,8 +2998,11 @@ def update_auth_settings(cmd, resource_group_name, name, enabled=None, action=No auth_settings_v2 = _get_auth_settings_v2(cmd, resource_group_name, name, slot) if _is_auth_v2_app(auth_settings_v2): use_v2 = True - except Exception: # pylint: disable=broad-except - pass + except HttpResponseError as ex: + # Only treat 404 as "v2 not available"; propagate other HTTP errors + status_code = getattr(getattr(ex, 'response', None), 'status_code', None) + if status_code != 404: + raise # v2-only params force v2 path for new auth setups if require_https is not None: diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 02a4fe5c924..d7a70b93d78 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -746,11 +746,17 @@ def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): # Verify update_auth_settings_v2 was called calls = [c for c in mock_site_op.call_args_list if c[0][3] == 'update_auth_settings_v2'] self.assertEqual(len(calls), 1) - # Verify the v2 settings were modified - sent_settings = calls[0][1].get('extra_parameter') or calls[0][0][4] if len(calls[0][0]) > 4 else None - # The auth settings object should have been passed as extra_parameter - update_call_args = calls[0] - self.assertIn('update_auth_settings_v2', update_call_args[0]) + # Verify the v2 settings object passed as extra_parameter has expected changes + sent_settings = calls[0][1].get('extra_parameter') or (calls[0][0][5] if len(calls[0][0]) > 5 else None) + self.assertIsInstance(sent_settings, SiteAuthSettingsV2) + # Platform.enabled should reflect enabled='true' + self.assertIsNotNone(sent_settings.platform) + self.assertTrue(sent_settings.platform.enabled) + # AAD registration should reflect client_id + aad_provider = sent_settings.identity_providers.azure_active_directory + self.assertIsNotNone(aad_provider) + self.assertIsNotNone(aad_provider.registration) + self.assertEqual('test-client-id', aad_provider.registration.client_id) @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') def test_update_auth_settings_require_https_forces_v2(self, mock_site_op): @@ -804,12 +810,18 @@ def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): def test_update_auth_v2_aad_settings(self, mock_site_op): from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform v2_settings = SiteAuthSettingsV2(platform=AuthPlatform(enabled=True)) + mock_app_settings = mock.MagicMock() + mock_app_settings.properties = {} def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): if op == 'get_auth_settings_v2': return v2_settings if op == 'update_auth_settings_v2': return extra_parameter + if op == 'list_application_settings': + return mock_app_settings + if op == 'update_application_settings': + return extra_parameter return mock.MagicMock() mock_site_op.side_effect = side_effect @@ -827,22 +839,33 @@ def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): aad = result.identity_providers.azure_active_directory self.assertTrue(aad.enabled) self.assertEqual(aad.registration.client_id, 'my-client-id') - self.assertEqual(aad.registration.client_secret_setting_name, 'my-secret') + # client_secret_setting_name should reference the app setting name, not the secret value + self.assertEqual(aad.registration.client_secret_setting_name, + 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET') self.assertEqual(aad.registration.open_id_issuer, 'https://sts.windows.net/tenant-id/') self.assertEqual(aad.validation.allowed_audiences, ['https://myapp.azurewebsites.net']) # Verify token store self.assertTrue(result.login.token_store.enabled) + # Verify the secret was stored in app settings + self.assertEqual(mock_app_settings.properties['MICROSOFT_PROVIDER_AUTHENTICATION_SECRET'], + 'my-secret') @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') def test_update_auth_v2_facebook_settings(self, mock_site_op): from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform v2_settings = SiteAuthSettingsV2(platform=AuthPlatform(enabled=True)) + mock_app_settings = mock.MagicMock() + mock_app_settings.properties = {} def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): if op == 'get_auth_settings_v2': return v2_settings if op == 'update_auth_settings_v2': return extra_parameter + if op == 'list_application_settings': + return mock_app_settings + if op == 'update_application_settings': + return extra_parameter return mock.MagicMock() mock_site_op.side_effect = side_effect @@ -857,8 +880,13 @@ def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): fb = result.identity_providers.facebook self.assertTrue(fb.enabled) self.assertEqual(fb.registration.app_id, 'fb-app-id') - self.assertEqual(fb.registration.app_secret_setting_name, 'fb-secret') + # app_secret_setting_name should reference the app setting name, not the secret value + self.assertEqual(fb.registration.app_secret_setting_name, + 'FACEBOOK_PROVIDER_AUTHENTICATION_SECRET') self.assertEqual(fb.login.scopes, ['public_profile', 'email']) + # Verify the secret was stored in app settings + self.assertEqual(mock_app_settings.properties['FACEBOOK_PROVIDER_AUTHENTICATION_SECRET'], + 'fb-secret') @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') def test_update_auth_v2_action_allow_anonymous(self, mock_site_op): From ee4eae7845eca7fed2806a3e5146df0eda3d252e Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 16:33:07 -0400 Subject: [PATCH 4/5] Add v1 backward-compat fields to v2 auth settings output When auth show/update returns a v2 (SiteAuthSettingsV2) response, flatten key v2 fields into top-level v1 aliases so that existing scripts and live tests that reference flat field names (enabled, defaultProvider, tokenStoreEnabled, clientId, etc.) continue to work without modification. - Add _add_v1_compat_fields() helper that converts a v2 model to a dict containing both the nested v2 structure and flat v1 keys. - Apply the compat shim in get_auth_settings (auth show) and update_auth_settings (auth update) when the v2 path is taken. - Pass through original secret values (clientSecret, facebookAppSecret) as v1 compat overrides so they are not replaced by the v2 setting-name references. - Update mock tests to verify both v2 nested keys and v1 compat fields; add dedicated unit tests for _add_v1_compat_fields(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/custom.py | 81 +++++++++- .../latest/test_webapp_commands_thru_mock.py | 153 +++++++++++++++--- 2 files changed, 210 insertions(+), 24 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index ecd4b958a18..54d2135b2e5 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -2746,11 +2746,80 @@ def _is_auth_v2_app(auth_settings_v2): return False +# Mapping from v2 redirect_to_provider values to v1 defaultProvider enum names. +_V2_PROVIDER_TO_V1_DEFAULT = { + 'azureactivedirectory': 'AzureActiveDirectory', + 'facebook': 'Facebook', + 'google': 'Google', + 'microsoftaccount': 'MicrosoftAccount', + 'twitter': 'Twitter', +} + + +def _add_v1_compat_fields(v2_model, secret_overrides=None): + """Add backward-compatible flat v1 fields to a v2 auth settings response. + + Converts the v2 model to a dict and adds top-level aliases for the v1 + field names so that callers relying on the flat v1 structure (e.g. + ``az webapp auth show --query enabled``) continue to work. + """ + from azure.cli.core.util import todict + + result = todict(v2_model) + if secret_overrides is None: + secret_overrides = {} + + # platform → flat fields + platform = result.get('platform') or {} + result['enabled'] = platform.get('enabled', False) + result['runtimeVersion'] = platform.get('runtimeVersion') + + # globalValidation → flat fields + gv = result.get('globalValidation') or {} + result['unauthenticatedClientAction'] = gv.get('unauthenticatedClientAction') + redirect_provider = gv.get('redirectToProvider') + result['defaultProvider'] = ( + _V2_PROVIDER_TO_V1_DEFAULT.get(redirect_provider, redirect_provider) + if redirect_provider else None) + + # login / tokenStore → flat fields + login = result.get('login') or {} + ts = login.get('tokenStore') or {} + result['tokenStoreEnabled'] = ts.get('enabled') + result['tokenRefreshExtensionHours'] = ts.get('tokenRefreshExtensionHours') + result['allowedExternalRedirectUrls'] = login.get('allowedExternalRedirectUrls') + + # identityProviders → flat fields + ip = result.get('identityProviders') or {} + + # Azure Active Directory + aad = ip.get('azureActiveDirectory') or {} + aad_reg = aad.get('registration') or {} + aad_val = aad.get('validation') or {} + result['clientId'] = aad_reg.get('clientId') + result['clientSecretCertificateThumbprint'] = aad_reg.get('clientSecretCertificateThumbprint') + result['issuer'] = aad_reg.get('openIdIssuer') + result['allowedAudiences'] = aad_val.get('allowedAudiences') + result['clientSecret'] = ( + secret_overrides.get('clientSecret') or aad_reg.get('clientSecretSettingName')) + + # Facebook + fb = ip.get('facebook') or {} + fb_reg = fb.get('registration') or {} + fb_login = fb.get('login') or {} + result['facebookAppId'] = fb_reg.get('appId') + result['facebookAppSecret'] = ( + secret_overrides.get('facebookAppSecret') or fb_reg.get('appSecretSettingName')) + result['facebookOauthScopes'] = fb_login.get('scopes') + + return result + + def get_auth_settings(cmd, resource_group_name, name, slot=None): try: auth_settings_v2 = _get_auth_settings_v2(cmd, resource_group_name, name, slot) if _is_auth_v2_app(auth_settings_v2): - return auth_settings_v2 + return _add_v1_compat_fields(auth_settings_v2) except HttpResponseError as ex: status_code = getattr(ex, 'status_code', None) if status_code not in (404, None): @@ -3012,7 +3081,7 @@ def update_auth_settings(cmd, resource_group_name, name, enabled=None, action=No if auth_settings_v2 is None: from azure.mgmt.web.models import SiteAuthSettingsV2 auth_settings_v2 = SiteAuthSettingsV2() - return _update_auth_settings_v2( + v2_result = _update_auth_settings_v2( cmd, resource_group_name, name, auth_settings_v2, enabled=enabled, action=action, client_id=client_id, token_store_enabled=token_store_enabled, runtime_version=runtime_version, @@ -3031,6 +3100,14 @@ def update_auth_settings(cmd, resource_group_name, name, enabled=None, action=No microsoft_account_client_secret=microsoft_account_client_secret, microsoft_account_oauth_scopes=microsoft_account_oauth_scopes, require_https=require_https, slot=slot) + # Pass through original secret values for backward-compatible fields, + # since v2 stores setting names rather than raw secret values. + secret_overrides = {} + if client_secret is not None: + secret_overrides['clientSecret'] = client_secret + if facebook_app_secret is not None: + secret_overrides['facebookAppSecret'] = facebook_app_secret + return _add_v1_compat_fields(v2_result, secret_overrides=secret_overrides) # v1 path auth_settings = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get_auth_settings', slot) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index d7a70b93d78..400cfbe7fe7 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -39,6 +39,7 @@ _is_auth_v2_app, _get_auth_settings_v2, _update_auth_settings_v2, + _add_v1_compat_fields, config_source_control) # pylint: disable=line-too-long @@ -673,6 +674,96 @@ def test_is_auth_v2_app_with_identity_providers(self): azure_active_directory=AzureActiveDirectory(enabled=True))) self.assertTrue(_is_auth_v2_app(settings)) + def test_add_v1_compat_fields_empty_v2(self): + """v1 compat fields default to None/False for an unconfigured v2 app.""" + from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform + v2 = SiteAuthSettingsV2(platform=AuthPlatform(enabled=False)) + result = _add_v1_compat_fields(v2) + self.assertIsInstance(result, dict) + self.assertFalse(result['enabled']) + self.assertIsNone(result['defaultProvider']) + self.assertIsNone(result['unauthenticatedClientAction']) + self.assertIsNone(result['tokenStoreEnabled']) + self.assertIsNone(result['clientId']) + self.assertIsNone(result['clientSecret']) + self.assertIsNone(result['facebookAppId']) + self.assertIsNone(result['facebookAppSecret']) + self.assertIsNone(result['facebookOauthScopes']) + + def test_add_v1_compat_fields_populated_v2(self): + """v1 compat fields are populated from the full v2 structure.""" + from azure.mgmt.web.models import ( + SiteAuthSettingsV2, AuthPlatform, GlobalValidation, + IdentityProviders, AzureActiveDirectory, + AzureActiveDirectoryRegistration, AzureActiveDirectoryValidation, + Facebook, AppRegistration, LoginScopes, + Login, TokenStore) + v2 = SiteAuthSettingsV2( + platform=AuthPlatform(enabled=True, runtime_version='1.2.8'), + global_validation=GlobalValidation( + unauthenticated_client_action='RedirectToLoginPage', + redirect_to_provider='facebook'), + login=Login( + token_store=TokenStore(enabled=False, token_refresh_extension_hours=7.2)), + identity_providers=IdentityProviders( + azure_active_directory=AzureActiveDirectory( + enabled=True, + registration=AzureActiveDirectoryRegistration( + client_id='aad_client_id', + client_secret_setting_name='SECRET_SETTING', + client_secret_certificate_thumbprint='aad_thumbprint', + open_id_issuer='https://issuer_url'), + validation=AzureActiveDirectoryValidation( + allowed_audiences=['https://audience1'])), + facebook=Facebook( + enabled=True, + registration=AppRegistration( + app_id='facebook_id', + app_secret_setting_name='FB_SECRET_SETTING'), + login=LoginScopes(scopes=['public_profile', 'email'])))) + result = _add_v1_compat_fields(v2) + self.assertTrue(result['enabled']) + self.assertEqual(result['runtimeVersion'], '1.2.8') + self.assertEqual(result['unauthenticatedClientAction'], 'RedirectToLoginPage') + self.assertEqual(result['defaultProvider'], 'Facebook') + self.assertFalse(result['tokenStoreEnabled']) + self.assertEqual(result['tokenRefreshExtensionHours'], 7.2) + self.assertEqual(result['clientId'], 'aad_client_id') + self.assertEqual(result['clientSecret'], 'SECRET_SETTING') + self.assertEqual(result['clientSecretCertificateThumbprint'], 'aad_thumbprint') + self.assertEqual(result['issuer'], 'https://issuer_url') + self.assertEqual(result['allowedAudiences'], ['https://audience1']) + self.assertEqual(result['facebookAppId'], 'facebook_id') + self.assertEqual(result['facebookAppSecret'], 'FB_SECRET_SETTING') + self.assertEqual(result['facebookOauthScopes'], ['public_profile', 'email']) + + def test_add_v1_compat_fields_secret_overrides(self): + """Secret overrides replace setting names with actual secret values.""" + from azure.mgmt.web.models import ( + SiteAuthSettingsV2, AuthPlatform, IdentityProviders, + AzureActiveDirectory, AzureActiveDirectoryRegistration, + Facebook, AppRegistration) + v2 = SiteAuthSettingsV2( + platform=AuthPlatform(enabled=True), + identity_providers=IdentityProviders( + azure_active_directory=AzureActiveDirectory( + enabled=True, + registration=AzureActiveDirectoryRegistration( + client_id='cid', + client_secret_setting_name='SETTING_NAME')), + facebook=Facebook( + enabled=True, + registration=AppRegistration( + app_id='fbid', + app_secret_setting_name='FB_SETTING_NAME')))) + overrides = { + 'clientSecret': 'actual_aad_secret', + 'facebookAppSecret': 'actual_fb_secret', + } + result = _add_v1_compat_fields(v2, secret_overrides=overrides) + self.assertEqual(result['clientSecret'], 'actual_aad_secret') + self.assertEqual(result['facebookAppSecret'], 'actual_fb_secret') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') def test_get_auth_settings_returns_v2_when_configured(self, mock_site_op): from azure.mgmt.web.models import SiteAuthSettingsV2, AuthPlatform @@ -682,7 +773,11 @@ def test_get_auth_settings_returns_v2_when_configured(self, mock_site_op): cmd = _get_test_cmd() result = get_auth_settings(cmd, 'rg', 'myapp') - self.assertIsInstance(result, SiteAuthSettingsV2) + # Result is now a dict with v2 structure and v1 compat fields + self.assertIsInstance(result, dict) + self.assertTrue(result['platform']['enabled']) + # v1 compat field + self.assertTrue(result['enabled']) mock_site_op.assert_called_once_with(cmd.cli_ctx, 'rg', 'myapp', 'get_auth_settings_v2', None) @@ -742,7 +837,9 @@ def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): result = update_auth_settings(cmd, 'rg', 'myapp', enabled='true', client_id='test-client-id') - self.assertEqual(result, updated_v2) + # Result is now a dict with v2 structure and v1 compat fields + self.assertIsInstance(result, dict) + self.assertTrue(result['enabled']) # Verify update_auth_settings_v2 was called calls = [c for c in mock_site_op.call_args_list if c[0][3] == 'update_auth_settings_v2'] self.assertEqual(len(calls), 1) @@ -777,8 +874,8 @@ def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): result = update_auth_settings(cmd, 'rg', 'myapp', require_https='true') # Should have used v2 path due to --require-https - self.assertIsNotNone(result.http_settings) - self.assertTrue(result.http_settings.require_https) + self.assertIsNotNone(result['httpSettings']) + self.assertTrue(result['httpSettings']['requireHttps']) @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') def test_update_auth_settings_v1_fallback(self, mock_site_op): @@ -835,17 +932,21 @@ def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): issuer='https://sts.windows.net/tenant-id/', token_store_enabled='true') - # Verify AAD settings in v2 structure - aad = result.identity_providers.azure_active_directory - self.assertTrue(aad.enabled) - self.assertEqual(aad.registration.client_id, 'my-client-id') - # client_secret_setting_name should reference the app setting name, not the secret value - self.assertEqual(aad.registration.client_secret_setting_name, + # Verify AAD settings in v2 structure (dict with camelCase keys) + aad = result['identityProviders']['azureActiveDirectory'] + self.assertTrue(aad['enabled']) + self.assertEqual(aad['registration']['clientId'], 'my-client-id') + # clientSecretSettingName should reference the app setting name, not the secret value + self.assertEqual(aad['registration']['clientSecretSettingName'], 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET') - self.assertEqual(aad.registration.open_id_issuer, 'https://sts.windows.net/tenant-id/') - self.assertEqual(aad.validation.allowed_audiences, ['https://myapp.azurewebsites.net']) + self.assertEqual(aad['registration']['openIdIssuer'], 'https://sts.windows.net/tenant-id/') + self.assertEqual(aad['validation']['allowedAudiences'], ['https://myapp.azurewebsites.net']) # Verify token store - self.assertTrue(result.login.token_store.enabled) + self.assertTrue(result['login']['tokenStore']['enabled']) + # Verify v1 compat fields carry the actual secret value + self.assertEqual(result['clientSecret'], 'my-secret') + self.assertEqual(result['clientId'], 'my-client-id') + self.assertTrue(result['tokenStoreEnabled']) # Verify the secret was stored in app settings self.assertEqual(mock_app_settings.properties['MICROSOFT_PROVIDER_AUTHENTICATION_SECRET'], 'my-secret') @@ -877,13 +978,17 @@ def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): facebook_app_secret='fb-secret', facebook_oauth_scopes=['public_profile', 'email']) - fb = result.identity_providers.facebook - self.assertTrue(fb.enabled) - self.assertEqual(fb.registration.app_id, 'fb-app-id') - # app_secret_setting_name should reference the app setting name, not the secret value - self.assertEqual(fb.registration.app_secret_setting_name, + fb = result['identityProviders']['facebook'] + self.assertTrue(fb['enabled']) + self.assertEqual(fb['registration']['appId'], 'fb-app-id') + # appSecretSettingName should reference the app setting name, not the secret value + self.assertEqual(fb['registration']['appSecretSettingName'], 'FACEBOOK_PROVIDER_AUTHENTICATION_SECRET') - self.assertEqual(fb.login.scopes, ['public_profile', 'email']) + self.assertEqual(fb['login']['scopes'], ['public_profile', 'email']) + # Verify v1 compat fields carry the actual secret value + self.assertEqual(result['facebookAppId'], 'fb-app-id') + self.assertEqual(result['facebookAppSecret'], 'fb-secret') + self.assertEqual(result['facebookOauthScopes'], ['public_profile', 'email']) # Verify the secret was stored in app settings self.assertEqual(mock_app_settings.properties['FACEBOOK_PROVIDER_AUTHENTICATION_SECRET'], 'fb-secret') @@ -905,7 +1010,9 @@ def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): cmd = _get_test_cmd() result = update_auth_settings(cmd, 'rg', 'myapp', action='AllowAnonymous') - self.assertEqual(result.global_validation.unauthenticated_client_action, 'AllowAnonymous') + self.assertEqual(result['globalValidation']['unauthenticatedClientAction'], 'AllowAnonymous') + # v1 compat field + self.assertEqual(result['unauthenticatedClientAction'], 'AllowAnonymous') @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') def test_update_auth_v2_action_login_with_aad(self, mock_site_op): @@ -925,8 +1032,10 @@ def side_effect(cli_ctx, rg, name, op, slot=None, extra_parameter=None): result = update_auth_settings(cmd, 'rg', 'myapp', action='LoginWithAzureActiveDirectory') - self.assertEqual(result.global_validation.unauthenticated_client_action, 'RedirectToLoginPage') - self.assertEqual(result.global_validation.redirect_to_provider, 'azureactivedirectory') + self.assertEqual(result['globalValidation']['unauthenticatedClientAction'], 'RedirectToLoginPage') + self.assertEqual(result['globalValidation']['redirectToProvider'], 'azureactivedirectory') + # v1 compat field + self.assertEqual(result['defaultProvider'], 'AzureActiveDirectory') class TestServicePrincipalDeploymentSource(unittest.TestCase): """Tests for Service Principal authentication detection in deployment source config""" From d65c677b1b0eb6697031b6e3c5ffe899768d9a49 Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Fri, 27 Mar 2026 10:34:54 -0400 Subject: [PATCH 5/5] Refactor auth functions to fix pylint too-many-branches/returns Extract _update_auth_settings_v2 (61 branches) into 9 focused helper functions (_configure_auth_v2_platform, _configure_auth_v2_global_validation, _configure_auth_v2_login, _configure_auth_v2_http_settings, _configure_auth_v2_aad, _configure_auth_v2_facebook, _configure_auth_v2_google, _configure_auth_v2_twitter, _configure_auth_v2_microsoft_account), reducing the main function to 3 branches. Consolidate _is_auth_v2_app returns from 11 to 4 using any() patterns. No behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/custom.py | 388 ++++++++++-------- 1 file changed, 218 insertions(+), 170 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 54d2135b2e5..2185c180b5e 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -2726,24 +2726,12 @@ def _is_auth_v2_app(auth_settings_v2): # Check for any configured identity provider, not just Azure Active Directory. identity_providers = getattr(auth_settings_v2, 'identity_providers', None) if identity_providers: - if getattr(identity_providers, 'azure_active_directory', None): - return True - if getattr(identity_providers, 'facebook', None): - return True - if getattr(identity_providers, 'google', None): - return True - if getattr(identity_providers, 'twitter', None): - return True - if getattr(identity_providers, 'microsoft', None): + provider_attrs = ('azure_active_directory', 'facebook', 'google', 'twitter', 'microsoft') + if any(getattr(identity_providers, attr, None) for attr in provider_attrs): return True # Presence of other v2-specific configuration sections also indicates v2 auth. - if getattr(auth_settings_v2, 'http_settings', None) is not None: - return True - if getattr(auth_settings_v2, 'login', None) is not None: - return True - if getattr(auth_settings_v2, 'global_validation', None) is not None: - return True - return False + return any(getattr(auth_settings_v2, attr, None) is not None + for attr in ('http_settings', 'login', 'global_validation')) # Mapping from v2 redirect_to_provider values to v1 defaultProvider enum names. @@ -2848,33 +2836,9 @@ def is_auth_runtime_version_valid(runtime_version=None): return True -def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, - enabled=None, action=None, client_id=None, - token_store_enabled=None, runtime_version=None, - token_refresh_extension_hours=None, - allowed_external_redirect_urls=None, client_secret=None, - client_secret_certificate_thumbprint=None, - allowed_audiences=None, issuer=None, - facebook_app_id=None, facebook_app_secret=None, - facebook_oauth_scopes=None, - twitter_consumer_key=None, twitter_consumer_secret=None, - google_client_id=None, google_client_secret=None, - google_oauth_scopes=None, - microsoft_account_client_id=None, - microsoft_account_client_secret=None, - microsoft_account_oauth_scopes=None, - require_https=None, slot=None): - """Apply parameter updates to a SiteAuthSettingsV2 object and persist it.""" - from azure.mgmt.web.models import ( - AuthPlatform, GlobalValidation, IdentityProviders, - Login, HttpSettings, TokenStore, - AzureActiveDirectory, AzureActiveDirectoryRegistration, - AzureActiveDirectoryValidation, - Facebook, AppRegistration, LoginScopes, - Google, ClientRegistration, - Twitter, TwitterRegistration) - - # -- platform -- +def _configure_auth_v2_platform(auth_settings_v2, enabled, runtime_version): + """Configure the platform block of a v2 auth settings object.""" + from azure.mgmt.web.models import AuthPlatform if auth_settings_v2.platform is None: auth_settings_v2.platform = AuthPlatform() if enabled is not None: @@ -2882,24 +2846,32 @@ def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, if runtime_version is not None: auth_settings_v2.platform.runtime_version = runtime_version - # -- global_validation -- - if action is not None: - if auth_settings_v2.global_validation is None: - auth_settings_v2.global_validation = GlobalValidation() - if action == 'AllowAnonymous': - auth_settings_v2.global_validation.unauthenticated_client_action = 'AllowAnonymous' - else: - auth_settings_v2.global_validation.unauthenticated_client_action = 'RedirectToLoginPage' - provider_map = { - 'LoginWithAzureActiveDirectory': 'azureactivedirectory', - 'LoginWithFacebook': 'facebook', - 'LoginWithGoogle': 'google', - 'LoginWithMicrosoftAccount': 'microsoftaccount', - 'LoginWithTwitter': 'twitter', - } - auth_settings_v2.global_validation.redirect_to_provider = provider_map.get(action, action) - # -- login / token_store -- +def _configure_auth_v2_global_validation(auth_settings_v2, action): + """Configure the global_validation block of a v2 auth settings object.""" + if action is None: + return + from azure.mgmt.web.models import GlobalValidation + if auth_settings_v2.global_validation is None: + auth_settings_v2.global_validation = GlobalValidation() + if action == 'AllowAnonymous': + auth_settings_v2.global_validation.unauthenticated_client_action = 'AllowAnonymous' + else: + auth_settings_v2.global_validation.unauthenticated_client_action = 'RedirectToLoginPage' + provider_map = { + 'LoginWithAzureActiveDirectory': 'azureactivedirectory', + 'LoginWithFacebook': 'facebook', + 'LoginWithGoogle': 'google', + 'LoginWithMicrosoftAccount': 'microsoftaccount', + 'LoginWithTwitter': 'twitter', + } + auth_settings_v2.global_validation.redirect_to_provider = provider_map.get(action, action) + + +def _configure_auth_v2_login(auth_settings_v2, token_store_enabled, token_refresh_extension_hours, + allowed_external_redirect_urls): + """Configure the login / token_store block of a v2 auth settings object.""" + from azure.mgmt.web.models import Login, TokenStore if auth_settings_v2.login is None: auth_settings_v2.login = Login() if token_store_enabled is not None or token_refresh_extension_hours is not None: @@ -2912,11 +2884,177 @@ def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, if allowed_external_redirect_urls is not None: auth_settings_v2.login.allowed_external_redirect_urls = allowed_external_redirect_urls - # -- http_settings -- - if require_https is not None: - if auth_settings_v2.http_settings is None: - auth_settings_v2.http_settings = HttpSettings() - auth_settings_v2.http_settings.require_https = require_https == 'true' + +def _configure_auth_v2_http_settings(auth_settings_v2, require_https): + """Configure the http_settings block of a v2 auth settings object.""" + if require_https is None: + return + from azure.mgmt.web.models import HttpSettings + if auth_settings_v2.http_settings is None: + auth_settings_v2.http_settings = HttpSettings() + auth_settings_v2.http_settings.require_https = require_https == 'true' + + +def _configure_auth_v2_aad(ip, client_id, client_secret, client_secret_certificate_thumbprint, + allowed_audiences, issuer): + """Configure Azure Active Directory identity provider and return any secrets to store.""" + secrets = {} + if not any(v is not None for v in [client_id, client_secret, client_secret_certificate_thumbprint, + allowed_audiences, issuer]): + return secrets + from azure.mgmt.web.models import (AzureActiveDirectory, AzureActiveDirectoryRegistration, + AzureActiveDirectoryValidation) + if ip.azure_active_directory is None: + ip.azure_active_directory = AzureActiveDirectory(enabled=True) + else: + ip.azure_active_directory.enabled = True + aad = ip.azure_active_directory + if aad.registration is None: + aad.registration = AzureActiveDirectoryRegistration() + if client_id is not None: + aad.registration.client_id = client_id + if client_secret is not None: + setting_name = 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET' + secrets[setting_name] = client_secret + aad.registration.client_secret_setting_name = setting_name + if client_secret_certificate_thumbprint is not None: + aad.registration.client_secret_certificate_thumbprint = client_secret_certificate_thumbprint + if issuer is not None: + aad.registration.open_id_issuer = issuer + if allowed_audiences is not None: + if aad.validation is None: + aad.validation = AzureActiveDirectoryValidation() + aad.validation.allowed_audiences = allowed_audiences + return secrets + + +def _configure_auth_v2_facebook(ip, facebook_app_id, facebook_app_secret, facebook_oauth_scopes): + """Configure Facebook identity provider and return any secrets to store.""" + secrets = {} + if not any(v is not None for v in [facebook_app_id, facebook_app_secret, facebook_oauth_scopes]): + return secrets + from azure.mgmt.web.models import Facebook, AppRegistration, LoginScopes + if ip.facebook is None: + ip.facebook = Facebook(enabled=True) + else: + ip.facebook.enabled = True + if facebook_app_id is not None or facebook_app_secret is not None: + if ip.facebook.registration is None: + ip.facebook.registration = AppRegistration() + if facebook_app_id is not None: + ip.facebook.registration.app_id = facebook_app_id + if facebook_app_secret is not None: + setting_name = 'FACEBOOK_PROVIDER_AUTHENTICATION_SECRET' + secrets[setting_name] = facebook_app_secret + ip.facebook.registration.app_secret_setting_name = setting_name + if facebook_oauth_scopes is not None: + if ip.facebook.login is None: + ip.facebook.login = LoginScopes() + ip.facebook.login.scopes = facebook_oauth_scopes + return secrets + + +def _configure_auth_v2_google(ip, google_client_id, google_client_secret, google_oauth_scopes): + """Configure Google identity provider and return any secrets to store.""" + secrets = {} + if not any(v is not None for v in [google_client_id, google_client_secret, google_oauth_scopes]): + return secrets + from azure.mgmt.web.models import Google, ClientRegistration, LoginScopes + if ip.google is None: + ip.google = Google(enabled=True) + else: + ip.google.enabled = True + if google_client_id is not None or google_client_secret is not None: + if ip.google.registration is None: + ip.google.registration = ClientRegistration() + if google_client_id is not None: + ip.google.registration.client_id = google_client_id + if google_client_secret is not None: + setting_name = 'GOOGLE_PROVIDER_AUTHENTICATION_SECRET' + secrets[setting_name] = google_client_secret + ip.google.registration.client_secret_setting_name = setting_name + if google_oauth_scopes is not None: + if ip.google.login is None: + ip.google.login = LoginScopes() + ip.google.login.scopes = google_oauth_scopes + return secrets + + +def _configure_auth_v2_twitter(ip, twitter_consumer_key, twitter_consumer_secret): + """Configure Twitter identity provider and return any secrets to store.""" + secrets = {} + if not any(v is not None for v in [twitter_consumer_key, twitter_consumer_secret]): + return secrets + from azure.mgmt.web.models import Twitter, TwitterRegistration + if ip.twitter is None: + ip.twitter = Twitter(enabled=True) + else: + ip.twitter.enabled = True + if ip.twitter.registration is None: + ip.twitter.registration = TwitterRegistration() + if twitter_consumer_key is not None: + ip.twitter.registration.consumer_key = twitter_consumer_key + if twitter_consumer_secret is not None: + setting_name = 'TWITTER_PROVIDER_AUTHENTICATION_SECRET' + secrets[setting_name] = twitter_consumer_secret + ip.twitter.registration.consumer_secret_setting_name = setting_name + return secrets + + +def _configure_auth_v2_microsoft_account(ip, microsoft_account_client_id, microsoft_account_client_secret, + microsoft_account_oauth_scopes): + """Configure Microsoft Account (legacy) identity provider and return any secrets to store.""" + secrets = {} + if not any(v is not None for v in [microsoft_account_client_id, microsoft_account_client_secret, + microsoft_account_oauth_scopes]): + return secrets + from azure.mgmt.web.models import ClientRegistration, LoginScopes + if ip.legacy_microsoft_account is None: + from azure.mgmt.web.models import LegacyMicrosoftAccount + ip.legacy_microsoft_account = LegacyMicrosoftAccount(enabled=True) + else: + ip.legacy_microsoft_account.enabled = True + if microsoft_account_client_id is not None or microsoft_account_client_secret is not None: + if ip.legacy_microsoft_account.registration is None: + ip.legacy_microsoft_account.registration = ClientRegistration() + if microsoft_account_client_id is not None: + ip.legacy_microsoft_account.registration.client_id = microsoft_account_client_id + if microsoft_account_client_secret is not None: + setting_name = 'MICROSOFTACCOUNT_PROVIDER_AUTHENTICATION_SECRET' + secrets[setting_name] = microsoft_account_client_secret + ip.legacy_microsoft_account.registration.client_secret_setting_name = setting_name + if microsoft_account_oauth_scopes is not None: + if ip.legacy_microsoft_account.login is None: + ip.legacy_microsoft_account.login = LoginScopes() + ip.legacy_microsoft_account.login.scopes = microsoft_account_oauth_scopes + return secrets + + +def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, + enabled=None, action=None, client_id=None, + token_store_enabled=None, runtime_version=None, + token_refresh_extension_hours=None, + allowed_external_redirect_urls=None, client_secret=None, + client_secret_certificate_thumbprint=None, + allowed_audiences=None, issuer=None, + facebook_app_id=None, facebook_app_secret=None, + facebook_oauth_scopes=None, + twitter_consumer_key=None, twitter_consumer_secret=None, + google_client_id=None, google_client_secret=None, + google_oauth_scopes=None, + microsoft_account_client_id=None, + microsoft_account_client_secret=None, + microsoft_account_oauth_scopes=None, + require_https=None, slot=None): + """Apply parameter updates to a SiteAuthSettingsV2 object and persist it.""" + from azure.mgmt.web.models import IdentityProviders + + _configure_auth_v2_platform(auth_settings_v2, enabled, runtime_version) + _configure_auth_v2_global_validation(auth_settings_v2, action) + _configure_auth_v2_login(auth_settings_v2, token_store_enabled, + token_refresh_extension_hours, + allowed_external_redirect_urls) + _configure_auth_v2_http_settings(auth_settings_v2, require_https) # -- identity_providers -- if auth_settings_v2.identity_providers is None: @@ -2927,120 +3065,30 @@ def _update_auth_settings_v2(cmd, resource_group_name, name, auth_settings_v2, # The v2 auth model *_secret_setting_name fields expect the name of an app setting, # not the secret value itself. secrets_to_store = {} - - # AAD - if any(v is not None for v in [client_id, client_secret, client_secret_certificate_thumbprint, - allowed_audiences, issuer]): - if ip.azure_active_directory is None: - ip.azure_active_directory = AzureActiveDirectory(enabled=True) - else: - ip.azure_active_directory.enabled = True - aad = ip.azure_active_directory - if aad.registration is None: - aad.registration = AzureActiveDirectoryRegistration() - if client_id is not None: - aad.registration.client_id = client_id - if client_secret is not None: - setting_name = 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET' - secrets_to_store[setting_name] = client_secret - aad.registration.client_secret_setting_name = setting_name - if client_secret_certificate_thumbprint is not None: - aad.registration.client_secret_certificate_thumbprint = client_secret_certificate_thumbprint - if issuer is not None: - aad.registration.open_id_issuer = issuer - if allowed_audiences is not None: - if aad.validation is None: - aad.validation = AzureActiveDirectoryValidation() - aad.validation.allowed_audiences = allowed_audiences - - # Facebook - if any(v is not None for v in [facebook_app_id, facebook_app_secret, facebook_oauth_scopes]): - if ip.facebook is None: - ip.facebook = Facebook(enabled=True) - else: - ip.facebook.enabled = True - if facebook_app_id is not None or facebook_app_secret is not None: - if ip.facebook.registration is None: - ip.facebook.registration = AppRegistration() - if facebook_app_id is not None: - ip.facebook.registration.app_id = facebook_app_id - if facebook_app_secret is not None: - setting_name = 'FACEBOOK_PROVIDER_AUTHENTICATION_SECRET' - secrets_to_store[setting_name] = facebook_app_secret - ip.facebook.registration.app_secret_setting_name = setting_name - if facebook_oauth_scopes is not None: - if ip.facebook.login is None: - ip.facebook.login = LoginScopes() - ip.facebook.login.scopes = facebook_oauth_scopes - - # Google - if any(v is not None for v in [google_client_id, google_client_secret, google_oauth_scopes]): - if ip.google is None: - ip.google = Google(enabled=True) - else: - ip.google.enabled = True - if google_client_id is not None or google_client_secret is not None: - if ip.google.registration is None: - ip.google.registration = ClientRegistration() - if google_client_id is not None: - ip.google.registration.client_id = google_client_id - if google_client_secret is not None: - setting_name = 'GOOGLE_PROVIDER_AUTHENTICATION_SECRET' - secrets_to_store[setting_name] = google_client_secret - ip.google.registration.client_secret_setting_name = setting_name - if google_oauth_scopes is not None: - if ip.google.login is None: - ip.google.login = LoginScopes() - ip.google.login.scopes = google_oauth_scopes - - # Twitter - if any(v is not None for v in [twitter_consumer_key, twitter_consumer_secret]): - if ip.twitter is None: - ip.twitter = Twitter(enabled=True) - else: - ip.twitter.enabled = True - if ip.twitter.registration is None: - ip.twitter.registration = TwitterRegistration() - if twitter_consumer_key is not None: - ip.twitter.registration.consumer_key = twitter_consumer_key - if twitter_consumer_secret is not None: - setting_name = 'TWITTER_PROVIDER_AUTHENTICATION_SECRET' - secrets_to_store[setting_name] = twitter_consumer_secret - ip.twitter.registration.consumer_secret_setting_name = setting_name - - # Microsoft Account (legacy) - if any(v is not None for v in [microsoft_account_client_id, microsoft_account_client_secret, - microsoft_account_oauth_scopes]): - if ip.legacy_microsoft_account is None: - from azure.mgmt.web.models import LegacyMicrosoftAccount - ip.legacy_microsoft_account = LegacyMicrosoftAccount(enabled=True) - else: - ip.legacy_microsoft_account.enabled = True - if microsoft_account_client_id is not None or microsoft_account_client_secret is not None: - if ip.legacy_microsoft_account.registration is None: - ip.legacy_microsoft_account.registration = ClientRegistration() - if microsoft_account_client_id is not None: - ip.legacy_microsoft_account.registration.client_id = microsoft_account_client_id - if microsoft_account_client_secret is not None: - setting_name = 'MICROSOFTACCOUNT_PROVIDER_AUTHENTICATION_SECRET' - secrets_to_store[setting_name] = microsoft_account_client_secret - ip.legacy_microsoft_account.registration.client_secret_setting_name = setting_name - if microsoft_account_oauth_scopes is not None: - if ip.legacy_microsoft_account.login is None: - ip.legacy_microsoft_account.login = LoginScopes() - ip.legacy_microsoft_account.login.scopes = microsoft_account_oauth_scopes + secrets_to_store.update(_configure_auth_v2_aad( + ip, client_id, client_secret, client_secret_certificate_thumbprint, + allowed_audiences, issuer)) + secrets_to_store.update(_configure_auth_v2_facebook( + ip, facebook_app_id, facebook_app_secret, facebook_oauth_scopes)) + secrets_to_store.update(_configure_auth_v2_google( + ip, google_client_id, google_client_secret, google_oauth_scopes)) + secrets_to_store.update(_configure_auth_v2_twitter( + ip, twitter_consumer_key, twitter_consumer_secret)) + secrets_to_store.update(_configure_auth_v2_microsoft_account( + ip, microsoft_account_client_id, microsoft_account_client_secret, + microsoft_account_oauth_scopes)) # Store all collected secrets into app settings in a single API call. if secrets_to_store: app_settings = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, - 'list_application_settings', slot) + 'list_application_settings', slot) for setting_key, secret_value in secrets_to_store.items(): app_settings.properties[setting_key] = secret_value _generic_site_operation(cmd.cli_ctx, resource_group_name, name, - 'update_application_settings', slot, app_settings) + 'update_application_settings', slot, app_settings) return _generic_site_operation(cmd.cli_ctx, resource_group_name, name, - 'update_auth_settings_v2', slot, auth_settings_v2) + 'update_auth_settings_v2', slot, auth_settings_v2) def update_auth_settings(cmd, resource_group_name, name, enabled=None, action=None, # pylint: disable=unused-argument