diff --git a/README.md b/README.md index 937bba822d..44be22df82 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The repo includes sample data so it's ready to try end to end. In this sample ap > **IMPORTANT:** In order to deploy and run this example, you'll need an **Azure subscription with access enabled for the Azure OpenAI service**. You can request access [here](https://aka.ms/oaiapply). You can also visit [here](https://azure.microsoft.com/free/cognitive-search/) to get some free Azure credits to get you started. -## Azure deployment +## Azure deployment ### Cost estimation @@ -160,9 +160,22 @@ To see any exceptions and server errors, navigate to the "Investigate -> Failure ### Enabling authentication -By default, the deployed Azure web app will have no authentication or access restrictions enabled, meaning anyone with routable network access to the web app can chat with your indexed data. You can require authentication to your Azure Active Directory by following the [Add app authentication](https://learn.microsoft.com/azure/app-service/scenario-secure-app-authentication-app-service) tutorial and set it up against the deployed web app. +By default, the deployed Azure web app will have no authentication or access restrictions enabled, meaning anyone with routable network access to the web app can chat with your indexed data. -To then limit access to a specific set of users or groups, you can follow the steps from [Restrict your Azure AD app to a set of users](https://learn.microsoft.com/azure/active-directory/develop/howto-restrict-your-app-to-a-set-of-users) by changing "Assignment Required?" option under the Enterprise Application, and then assigning users/groups access. Users not granted explicit access will receive the error message -AADSTS50105: Your administrator has configured the application to block users unless they are specifically granted ('assigned') access to the application.- +To enable [AAD-based App Service authentication](https://learn.microsoft.com/azure/app-service/scenario-secure-app-authentication-app-service), set the `AZURE_USE_AUTHENTICATION` variable to true before running `azd up`: + +1. Run `azd env set AZURE_USE_AUTHENTICATION true` +1. Run `azd up` + +When that is true, `azd up` will enable Azure authentication for the App Service app by: + +* Using a preprovision hook to call `auth_init.py` to create an App Registration. That script sets the `AZURE_AUTH_APP_ID`, `AZURE_AUTH_CLIENT_ID`, and `AZURE_AUTH_CLIENT_SECRET` environment variables. +* During provisioning, using configuration in `appservice.bicep` to set the registered app as the authentication provider for the App Service app. +* Using a postprovision hook to call `auth_update.py` to set the redirect URI to the URL of the deployed App Service app + +The web app code does not currently contain any login/logout links, as the App Service app is configured to redirect logged out users automatically. You may add those links to the web app code if you want to. + +To limit access to a specific set of users or groups, you can follow the steps from [Restrict your Azure AD app to a set of users](https://learn.microsoft.com/azure/active-directory/develop/howto-restrict-your-app-to-a-set-of-users) by changing "Assignment Required?" option under the Enterprise Application, and then assigning users/groups access. Users not granted explicit access will receive the error message -AADSTS50105: Your administrator has configured the application to block users unless they are specifically granted ('assigned') access to the application.- ## Running locally diff --git a/azure.yaml b/azure.yaml index 6c323bb8c7..0b9264ef2b 100644 --- a/azure.yaml +++ b/azure.yaml @@ -21,14 +21,25 @@ services: interactive: true continueOnError: false hooks: + preprovision: + windows: + shell: pwsh + run: ./scripts/auth_init.ps1 + interactive: true + continueOnError: false + posix: + shell: sh + run: ./scripts/auth_init.sh + interactive: true + continueOnError: false postprovision: windows: shell: pwsh - run: ./scripts/prepdocs.ps1 + run: ./scripts/auth_update.ps1;./scripts/prepdocs.ps1 interactive: true continueOnError: false posix: shell: sh - run: ./scripts/prepdocs.sh + run: ./scripts/auth_update.sh;./scripts/prepdocs.sh interactive: true continueOnError: false diff --git a/infra/core/host/appservice.bicep b/infra/core/host/appservice.bicep index c1de148a2d..eedee3539d 100644 --- a/infra/core/host/appservice.bicep +++ b/infra/core/host/appservice.bicep @@ -24,6 +24,10 @@ param allowedOrigins array = [] param alwaysOn bool = true param appCommandLine string = '' param appSettings object = {} +param authClientId string = '' +@secure() +param authClientSecret string = '' +param authIssuerUri string = '' param clientAffinityEnabled bool = false param enableOryxBuild bool = contains(kind, 'linux') param functionAppScaleLimit int = -1 @@ -72,7 +76,9 @@ resource appService 'Microsoft.Web/sites@2022-03-01' = { }, runtimeName == 'python' ? { PYTHON_ENABLE_GUNICORN_MULTIWORKERS: 'true'} : {}, !empty(applicationInsightsName) ? { APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString } : {}, - !empty(keyVaultName) ? { AZURE_KEY_VAULT_ENDPOINT: keyVault.properties.vaultUri } : {}) + !empty(keyVaultName) ? { AZURE_KEY_VAULT_ENDPOINT: keyVault.properties.vaultUri } : {}, + !empty(authClientSecret) ? { AZURE_AUTH_CLIENT_SECRET: authClientSecret } : {} + ) } resource configLogs 'config' = { @@ -87,6 +93,37 @@ resource appService 'Microsoft.Web/sites@2022-03-01' = { configAppSettings ] } + + resource configAuth 'config' = if (!(empty(authClientId))) { + name: 'authsettingsV2' + properties: { + globalValidation: { + requireAuthentication: true + unauthenticatedClientAction: 'RedirectToLoginPage' + redirectToProvider: 'azureactivedirectory' + } + identityProviders: { + azureActiveDirectory: { + enabled: true + registration: { + clientId: authClientId + clientSecretSettingName: 'AZURE_AUTH_CLIENT_SECRET' + openIdIssuer: authIssuerUri + } + validation: { + defaultAuthorizationPolicy: { + allowedApplications: [] + } + } + } + } + login: { + tokenStore: { + enabled: true + } + } + } + } } resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = if (!(empty(keyVaultName))) { diff --git a/infra/main.bicep b/infra/main.bicep index 547f37c594..d58ea5283f 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -54,6 +54,12 @@ param embeddingDeploymentName string = 'embedding' param embeddingDeploymentCapacity int = 30 param embeddingModelName string = 'text-embedding-ada-002' +// Used for the Azure AD application +param useAuthentication bool = false +param authClientId string = '' +@secure() +param authClientSecret string = '' + @description('Id of the user or app to assign application roles') param principalId string = '' @@ -128,6 +134,9 @@ module backend 'core/host/appservice.bicep' = { appCommandLine: 'python3 -m gunicorn main:app' scmDoBuildDuringDeployment: true managedIdentity: true + authClientId: useAuthentication ? authClientId : '' + authClientSecret: useAuthentication ? authClientSecret : '' + authIssuerUri: useAuthentication ? '${environment().authentication.loginEndpoint}${tenant().tenantId}/v2.0' : '' appSettings: { AZURE_STORAGE_ACCOUNT: storage.outputs.name AZURE_STORAGE_CONTAINER: storageContainerName @@ -358,4 +367,6 @@ output AZURE_STORAGE_ACCOUNT string = storage.outputs.name output AZURE_STORAGE_CONTAINER string = storageContainerName output AZURE_STORAGE_RESOURCE_GROUP string = storageResourceGroup.name +output AZURE_USE_AUTHENTICATION bool = useAuthentication + output BACKEND_URI string = backend.outputs.uri diff --git a/infra/main.parameters.json b/infra/main.parameters.json index df3f727f13..30ebffcebd 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -55,6 +55,15 @@ }, "useApplicationInsights": { "value": "${AZURE_USE_APPLICATION_INSIGHTS=false}" + }, + "useAuthentication": { + "value": "${AZURE_USE_AUTHENTICATION=false}" + }, + "authClientId": { + "value": "${AZURE_AUTH_CLIENT_ID}" + }, + "authClientSecret": { + "value": "${AZURE_AUTH_CLIENT_SECRET}" } } } diff --git a/scripts/auth_init.ps1 b/scripts/auth_init.ps1 new file mode 100755 index 0000000000..e55f56e086 --- /dev/null +++ b/scripts/auth_init.ps1 @@ -0,0 +1,9 @@ +. ./scripts/loadenv.ps1 + +$venvPythonPath = "./scripts/.venv/scripts/python.exe" +if (Test-Path -Path "/usr") { + # fallback to Linux venv path + $venvPythonPath = "./scripts/.venv/bin/python" +} + +Start-Process -FilePath $venvPythonPath -ArgumentList "./scripts/auth_init.py" -Wait -NoNewWindow diff --git a/scripts/auth_init.py b/scripts/auth_init.py new file mode 100644 index 0000000000..bf8a80abe1 --- /dev/null +++ b/scripts/auth_init.py @@ -0,0 +1,89 @@ +import os +import subprocess + +import urllib3 +from azure.identity import AzureDeveloperCliCredential + + +def get_auth_headers(credential): + return { + "Authorization": "Bearer " + + credential.get_token("https://graph.microsoft.com/.default").token + } + + +def check_for_application(credential, app_id): + resp = urllib3.request( + "GET", + f"https://graph.microsoft.com/v1.0/applications/{app_id}", + headers=get_auth_headers(credential), + ) + if resp.status != 200: + print("Application not found") + return False + return True + + +def create_application(credential): + resp = urllib3.request( + "POST", + "https://graph.microsoft.com/v1.0/applications", + headers=get_auth_headers(credential), + json={ + "displayName": "WebApp", + "signInAudience": "AzureADandPersonalMicrosoftAccount", + "web": { + "redirectUris": ["http://localhost:5000/.auth/login/aad/callback"], + "implicitGrantSettings": {"enableIdTokenIssuance": True}, + }, + }, + timeout=urllib3.Timeout(connect=10, read=10), + ) + + app_id = resp.json()["id"] + client_id = resp.json()["appId"] + + return app_id, client_id + + +def add_client_secret(credential, app_id): + resp = urllib3.request( + "POST", + f"https://graph.microsoft.com/v1.0/applications/{app_id}/addPassword", + headers=get_auth_headers(credential), + json={"passwordCredential": {"displayName": "WebAppSecret"}}, + timeout=urllib3.Timeout(connect=10, read=10), + ) + client_secret = resp.json()["secretText"] + return client_secret + + +def update_azd_env(name, val): + subprocess.run(f"azd env set {name} {val}", shell=True) + + +if __name__ == "__main__": + if os.getenv("AZURE_USE_AUTHENTICATION", "false") != "true": + print("AZURE_USE_AUTHENTICATION is false, not setting up authentication") + exit(0) + + print("AZURE_USE_AUTHENTICATION is true, setting up authentication...") + credential = AzureDeveloperCliCredential() + + app_id = os.getenv("AZURE_AUTH_APP_ID", "no-id") + if app_id != "no-id": + print(f"Checking if application {app_id} exists") + if check_for_application(credential, app_id): + print("Application already exists, not creating new one") + exit(0) + + print("Creating application registration") + app_id, client_id = create_application(credential) + + print(f"Adding client secret to {app_id}") + client_secret = add_client_secret(credential, app_id) + + print("Updating azd env with AZURE_AUTH_APP_ID, AZURE_AUTH_CLIENT_ID, AZURE_AUTH_CLIENT_SECRET") + update_azd_env("AZURE_AUTH_APP_ID", app_id) + update_azd_env("AZURE_AUTH_CLIENT_ID", client_id) + update_azd_env("AZURE_AUTH_CLIENT_SECRET", client_secret) diff --git a/scripts/auth_init.sh b/scripts/auth_init.sh new file mode 100755 index 0000000000..6b6ae1f82f --- /dev/null +++ b/scripts/auth_init.sh @@ -0,0 +1,5 @@ + #!/bin/sh + +. ./scripts/loadenv.sh + +./scripts/.venv/bin/python ./scripts/auth_init.py diff --git a/scripts/auth_update.ps1 b/scripts/auth_update.ps1 new file mode 100644 index 0000000000..332faa0d19 --- /dev/null +++ b/scripts/auth_update.ps1 @@ -0,0 +1,9 @@ +. ./scripts/loadenv.ps1 + +$venvPythonPath = "./scripts/.venv/scripts/python.exe" +if (Test-Path -Path "/usr") { + # fallback to Linux venv path + $venvPythonPath = "./scripts/.venv/bin/python" +} + +Start-Process -FilePath $venvPythonPath -ArgumentList "./scripts/auth_update.py" -Wait -NoNewWindow diff --git a/scripts/auth_update.py b/scripts/auth_update.py new file mode 100755 index 0000000000..b6d4cc4e65 --- /dev/null +++ b/scripts/auth_update.py @@ -0,0 +1,37 @@ +import os + +import urllib3 +from azure.identity import AzureDeveloperCliCredential + + +def update_redirect_uris(credential, app_id, uri): + urllib3.request( + "PATCH", + f"https://graph.microsoft.com/v1.0/applications/{app_id}", + headers={ + "Authorization": "Bearer " + + credential.get_token("https://graph.microsoft.com/.default").token, + }, + json={ + "web": { + "redirectUris": [ + "http://localhost:5000/.auth/login/aad/callback", + f"{uri}/.auth/login/aad/callback", + ] + } + }, + ) + + +if __name__ == "__main__": + if os.getenv("AZURE_USE_AUTHENTICATION", "false") != "true": + print("AZURE_USE_AUTHENTICATION is false, not updating authentication") + exit(0) + + print("AZURE_USE_AUTHENTICATION is true, updating authentication...") + credential = AzureDeveloperCliCredential() + + app_id = os.getenv("AZURE_AUTH_APP_ID") + uri = os.getenv("BACKEND_URI") + print(f"Updating application registration {app_id} with redirect URI for {uri}") + update_redirect_uris(credential, app_id, uri) diff --git a/scripts/auth_update.sh b/scripts/auth_update.sh new file mode 100755 index 0000000000..d2a9e3d4ef --- /dev/null +++ b/scripts/auth_update.sh @@ -0,0 +1,6 @@ + #!/bin/sh + +. ./scripts/loadenv.sh + +echo 'Running "auth_update.py"' +./scripts/.venv/bin/python ./scripts/auth_update.py --appid "$AUTH_APP_ID" --uri "$BACKEND_URI" diff --git a/scripts/loadenv.ps1 b/scripts/loadenv.ps1 new file mode 100644 index 0000000000..116abd741b --- /dev/null +++ b/scripts/loadenv.ps1 @@ -0,0 +1,30 @@ +Write-Host "Loading azd .env file from current environment" +$output = azd env get-values +foreach ($line in $output) { + if (!$line.Contains('=')) { + continue + } + + $name, $value = $line.Split("=") + $value = $value -replace '^\"|\"$' + [Environment]::SetEnvironmentVariable($name, $value) +} + + +$pythonCmd = Get-Command python -ErrorAction SilentlyContinue +if (-not $pythonCmd) { + # fallback to python3 if python not found + $pythonCmd = Get-Command python3 -ErrorAction SilentlyContinue +} + +Write-Host 'Creating python virtual environment "scripts/.venv"' +Start-Process -FilePath ($pythonCmd).Source -ArgumentList "-m venv ./scripts/.venv" -Wait -NoNewWindow + +$venvPythonPath = "./scripts/.venv/scripts/python.exe" +if (Test-Path -Path "/usr") { + # fallback to Linux venv path + $venvPythonPath = "./scripts/.venv/bin/python" +} + +Write-Host 'Installing dependencies from "requirements.txt" into virtual environment' +Start-Process -FilePath $venvPythonPath -ArgumentList "-m pip install -r scripts/requirements.txt" -Wait -NoNewWindow diff --git a/scripts/loadenv.sh b/scripts/loadenv.sh new file mode 100755 index 0000000000..102d997583 --- /dev/null +++ b/scripts/loadenv.sh @@ -0,0 +1,14 @@ +echo "Loading azd .env file from current environment" + +while IFS='=' read -r key value; do + value=$(echo "$value" | sed 's/^"//' | sed 's/"$//') + export "$key=$value" +done <