Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add optional AAD auth #534

Closed
wants to merge 12 commits into from
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <app_name> 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 <app_name> to block users unless they are specifically granted ('assigned') access to the application.-

## Running locally

Expand Down
15 changes: 13 additions & 2 deletions azure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 38 additions & 1 deletion infra/core/host/appservice.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' = {
Expand All @@ -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))) {
Expand Down
11 changes: 11 additions & 0 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
9 changes: 9 additions & 0 deletions infra/main.parameters.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}
}
}
9 changes: 9 additions & 0 deletions scripts/auth_init.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
. ./scripts/loadenv.ps1
pamelafox marked this conversation as resolved.
Show resolved Hide resolved

$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
89 changes: 89 additions & 0 deletions scripts/auth_init.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions scripts/auth_init.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh

. ./scripts/loadenv.sh
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: Calling loadenv.sh in every script (auth_init, auth_update, prepdocs) means that it pip installs requirements every time. I could just call it in the first script, but then other hooks won't work if a dev deletes the first script hook for some reason.
I believe I also tried calling load_env as its own hook, but then the other hooks executed in a separated environment and didnt have access to the exported variables.

Another approach is to dump the env variables into a .env file and load them that way, but that'd be a larger change. That is what I do in https://github.com/pamelafox/chatgpt-quickstart/blob/main/azure.yaml


./scripts/.venv/bin/python ./scripts/auth_init.py
9 changes: 9 additions & 0 deletions scripts/auth_update.ps1
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions scripts/auth_update.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions scripts/auth_update.sh
Original file line number Diff line number Diff line change
@@ -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"
30 changes: 30 additions & 0 deletions scripts/loadenv.ps1
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions scripts/loadenv.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
$(azd env get-values)
EOF

echo 'Creating Python virtual environment "scripts/.venv"'
python3 -m venv scripts/.venv

echo 'Installing dependencies from "requirements.txt" into virtual environment'
./scripts/.venv/bin/python -m pip install -r scripts/requirements.txt
Loading
Loading