Azure Static Web Apps est un service Azure d'hébergement d'applications Web intégrant nativement un système de CDN.
Son prix attractif (gratuit ou environ 9€ / mois dans sa version standard) et son efficacité en font une ressource intéressante pour la publication de sites web peu complexes, tels que des portfolios, des sites de documentation...
Dans cet article, nous allons voir ensemble comme configurer le Single Sign On (SSO) sur cette ressource avec une authentification via Azure Active Directory (désormais Microsoft Entra ID) pour interdire tout accès anonyme à celle-ci.
Nous verrons aussi comment utiliser ce même Azure Active Directory pour implémenter une couche d'autorisation selon les groupes de l'Azure Active Directory auquel chaque utilisateur appartient, pour filtrer le contenu visible par un utilisateur connecté selon des droits lui ayant été attribués.
En plus d'implémenter ces principes, il est recommandé de déployer votre site à l'intérieur d'un réseau privé s'il n'a pas besoin d'être accessible depuis Internet. Cela ne sera pas le cas ici pour nous concentrer sur le sujet de cet article.
Infrastructure
Nous utiliserons Terraform pour déployer notre infrastructure.
Notre site étant public, nous n'avons pas besoin de grand chose pour le déployer:
- Un resource group
- Une instance d'Azure Static Web App
- Une app registration avec des autorisations sur l'annuaire Azure Active Directory: elle permettra à notre site d'interagir avec l'Azure AD pour l'authentification et l'autorisation des utilisateurs.
Néanmoins, rien ne vous empêche d'ajouter d'autres ressources autour de tout cela selon votre architecture.
Nous partons également du principe que nous disposons déjà d'un autre resource group contenant un storage account pour accueillir notre tfstate.
Bien que nommé "Azure Static Web App", il est possible d'intégrer des APIs dans notre site fonctionnant comme des Azure Functions.
Nous utiliserons ce mécanisme pour assigner des rôles à nos utilisateurs lors de l'authentification un peu plus loin, mais vous pouvez aussi l'utiliser pour récupérer des ressources depuis une base de données par exemple.
Le code Terraform que nous allons utiliser et tout le reste du projet est disponible ici.
Configuration de l'Azure Static Web App
Azure Static Web App permet de gérer tous les mécanismes qui nous intéressent au travers d'un fichier de configuration staticwebapp.config.json.
Celui-ci est à placer à la racine du site.
Authentification
Deux méthodes d'authentification existent dans Azure Static Web App :
- La Service Defined Authentication : elle permet d'implémenter simplement le SSO pour les providers connus (Facebook, Azure AD, Google...)
- La Custom Authentication : elle permet d'implémenter le SSO avec n'importe quel provider supportant OpenID Connect. Cela inclut également les providers évoqués précédemment (car ils sont eux aussi basés sur OpenID Connect), mais avec des paramètres supplémentaires.
Dans notre cas, il faudra utiliser un SKU standard, car bien que nous utilisions une authentification Azure Active Directory, nous intercalons un appel à une API pendant le processus d'authentification, ce qui n'est faisable qu'avec un SKU Standard.
Celle-ci assignera des roles à nos utilisateurs, que nous pourrons utiliser par la suite pour gérer nos autorisations.
Voici ce que nous allons ajouter à notre fichier staticwebapp.config.json pour configurer une authentification via Azure Active Directory:
{
"platform": {
"apiRuntime": "node:16"
},
"auth": {
"rolesSource": "/api/roles",
"identityProviders": {
"azureActiveDirectory": {
"userDetailsClaim": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
"registration": {
"openIdIssuer": "https://login.microsoftonline.com/TenantId",
"clientIdSettingName": "AAD_CLIENT_ID",
"clientSecretSettingName": "AAD_CLIENT_SECRET"
},
"login": {
"loginParameters": [
"resource=https://graph.microsoft.com"
]
}
}
}
}
}
Il nous faut ajouter deux application settings nommées "AAD_CLIENT_ID" et "AAD_CLIENT_SECRET" sur notre Static Web App contenant respectivement l'application id et le mot de passe de notre app registration, afin de permettre à la Static Web App de l'utiliser pendant la phase d'authentification pour interroger notre Azure Active Directory.
Nous remplacerons également la valeur "TenantId" lors de la CI par l'ID de notre tenant, en récupérant sa valeur depuis une variable Azure DevOps.
Enfin, les champs apiRuntime et rolesSource serviront à notre api d'autorisation détaillée ci-dessous.
Autorisation
Concept et filtrage du contenu
Azure Static Web App possède un système de rôle permettant de filtrer le contenu visible par un utilisateur.
Par défaut, seul deux rôles existent et sont assignés automatiquement par la Static Web App :
- anonymous : est assigné à tous les visiteurs du site
- authenticated : est assigné à tous les visiteurs connectés du site
Avec ces deux seuls rôles, nous sommes assez limités. Pour aller plus loin, nous allons mettre en place l'API évoquée plus haut.
Appelée lors de la phase d'authentification de l'utilisateur, elle récupérera les groupes de l'Azure Active Directory auxquels ce dernier appartient et assignera des rôles personnalisés au sein de l'Azure Static Web App en fonction de son appartenance à certains groupes.
Ces rôles pourront ensuite être référencés dans le fichier staticwebapp.config.json pour autoriser l'accès aux diverses URLs du site si et seulement si un utilisateur a ses rôles assignés à son profil.
Voici à quoi peuvent ressembler ces autorisations :
{
"routes": [
{
"route": "/",
"allowedRoles": [
"user",
"administrator"
]
},
{
"route": "/docs/*",
"allowedRoles": [
"user",
"administrator"
]
},
{
"route": "/admin/*",
"allowedRoles": [
"administrator"
]
}
],
"responseOverrides": {
"401": {
"statusCode": 302,
"redirect": "/.auth/login/aad"
}
}
}
Vous remarquerez également le bloc responseOverrides supplémentaire. Par défaut, si un utilisateur essaie d'acéder à une URL possédant des restrictions sans être connecté, il reçoit une erreur un code de retour HTTP 401 (Unauthorized).
Avec cette configuration, on transforme automatiquement les 401 en 302 (Redirection), et on redirige donc tout utilisateur non connecté vers la page de connexion.
Si on veut que du contenu puisse être accessible anonymement, alors on peut simplement ne pas l'inclure dans ces routes avec restrictions.
Configuration de l'API
Revenons plus en détail sur nos deux champs platform et rolesSources évoqués plus haut.
Vous l'aurez peut être compris, le premier représente le langage à utiliser pour exécuter notre API, le second représente quant à lui la route à appeler.
{
"platform": {
"apiRuntime": "node:16"
},
"auth": {
"rolesSource": "/api/roles",
...
}
}
Les APIs doivent obligatoirement être créées dans un dossier à la racine du site nommé "api".
Chaque API est un sous dossier de ce dossier api.
Si le nom de ce sous dossier est en minuscule et sans espace (ex: health), alors par défaut l'URL associée à cette api est votre_site.fr/api/nom_de_la_route (ex: votre_site.fr/api/health).
Attention, avec un nom incorrect, l'api n'a pas de route par défaut et n'est pas appelable (ex: GetRoles n'est pas transformé en /api/getroles).
L'URL de chaque API peut également être définie dans les bindings du fichier function.json associé à chaque API, via le paramètre "route".
Voici à quoi va ressembler notre API "roles", en javascript :
const fetch = require("node-fetch").default;
const roleGroupMappings = {
user: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // object id du groupe associé dans Azure Active Directory
administrator: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" // object id du groupe associé dans Azure Active Directory
// groupes additionnels si besoin ici
};
module.exports = async function (context, req) {
const user = req.body || {};
const roles = [];
const groupsIdsOfUser = await getUserGroups(user.accessToken);
// Pour chaque groupe de l'Azure Active Directory défini ci-dessus,
// on regarde si l'utilisateur est dedans
// Si c'est le cas, on lui assigne le role dans l'Azure Static Web App associée
for (const [role, groupId] of Object.entries(roleGroupMappings)) {
if (groupsIdsOfUser.includes(groupId)) {
roles.push(role);
}
}
context.res.json({
roles,
});
};
// Fonction appelant Microsoft Graph pour récupérer les IDs
// des groupes de l'Azure Active Directory de l'utilisateur
async function getUserGroups(bearerToken) {
const url = new URL("https://graph.microsoft.com/v1.0/me/memberOf");
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
});
if (response.status !== 200) {
return [];
}
const graphResponse = await response.json();
// On ne récupère que les IDs des groupes
const ids = graphResponse.value.map((item) => item.id);
return ids;
}
Déploiement
Pour notre CI/CD, nous utiliserons Azure DevOps. Nous avons plusieurs étapes à réaliser:
- Build de notre code Terraform
- Build du code de notre site
- Exécution du code Terraform
- Déploiement du code de notre site
Voici le pipeline YAML mettant tout cela en place. Bien sûr, pensez à adapter le nom des variable groups passés en paramètres selon votre contexte.
variables:
- group: "DemoVariableGroup"
trigger:
- main
pool:
vmImage: "ubuntu-22.04"
stages:
- stage: "Build"
displayName: "Build"
jobs:
# Build du Terraform : on copie ses fichiers dans un artefact qu'on publie
- job: "Terraform"
displayName: "Terraform"
steps:
- task: CopyFiles@2
displayName: "Copy Terraform files"
inputs:
SourceFolder: "$(System.DefaultWorkingDirectory)/$(TerraformFolder)"
Contents: "**"
TargetFolder: $(Build.ArtifactStagingDirectory)
- publish: "$(Build.ArtifactStagingDirectory)"
artifact: infra
# Build du site
- job: "Website"
displayName: "Website"
steps:
# Remplacement du token pour l'ID du tenant
- task: qetza.replacetokens.replacetokens-task.replacetokens@5
displayName: "Replace tokens"
inputs:
rootDirectory: $(System.DefaultWorkingDirectory)/$(WebsiteFolder)
targetFiles: "staticwebapp.config.json"
tokenPattern: custom
tokenPrefix: "@#{"
tokenSuffix: "}#@"
actionOnMissing: fail
actionOnNoFiles: fail
enableTelemetry: false
# Installation de Node JS
- task: NodeTool@0
displayName: "Install Node JS"
inputs:
versionSpec: "16.x"
checkLatest: true
# Mise en cache des packages
- task: Cache@2
displayName: Cache API NPM packages
inputs:
key: "$(System.DefaultWorkingDirectory)/$(WebsiteFolder)/api/package-lock.json"
path: $(Pipeline.Workspace)/.npm
# Installation des packages
- script: |
npm ci --cache $(Pipeline.Workspace)/.npm
displayName: "Install API NPM packages"
workingDirectory: $(System.DefaultWorkingDirectory)/$(WebsiteFolder)/api
# Copie du code du site avec ses packages vers l'artefact
- task: CopyFiles@2
displayName: "Copy website files"
inputs:
SourceFolder: "$(System.DefaultWorkingDirectory)/$(WebsiteFolder)"
Contents: "**"
TargetFolder: $(Build.ArtifactStagingDirectory)
# Publication de l'artefact
- publish: "$(Build.ArtifactStagingDirectory)"
artifact: app
- stage: "Deployment"
displayName: "Deployment"
jobs:
- job: deployment
steps:
- download: current
artifact: infra
- download: current
artifact: app
- task: ms-devlabs.custom-terraform-tasks.custom-terraform-installer-task.TerraformInstaller@1
displayName: "Install Terraform $(TerraformVersion)"
inputs:
terraformVersion: $(TerraformVersion)
# On initialise Terraform
- task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV4@4
displayName: "Terraform init"
inputs:
workingDirectory: $(Pipeline.Workspace)/infra
# https://github.com/microsoft/azure-pipelines-agent/issues/1307
# La service connection doit être hardcodée
# Il n'est pas possible d'utiliser de variables pour le moment
backendServiceArm: "sbx"
backendAzureRmResourceGroupName: $(TfStateResourceGroupName)
backendAzureRmStorageAccountName: $(TfStateStorageAccountName)
backendAzureRmContainerName: $(TfStateStorageAccountContainerName)
backendAzureRmKey: $(TfStateName)
commandOptions: "-no-color"
- task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV4@4
displayName: "Terraform validate"
inputs:
command: validate
workingDirectory: $(Pipeline.Workspace)/infra
commandOptions: "-no-color"
- task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV3@4
displayName: "Terraform apply"
name: terraformApply
inputs:
command: apply
workingDirectory: $(Pipeline.Workspace)/infra
commandOptions: "-no-color -auto-approve"
# https://github.com/microsoft/azure-pipelines-agent/issues/1307
# La service connection doit être hardcodée
# Il n'est pas possible d'utiliser de variables pour le moment
environmentServiceNameAzureRM: "sbx"
# On récupère les outputs de terraform et on les transforme en variable Azure DevOps
- powershell: |
$terraformOutput = terraform output -json | ConvertFrom-Json
$terraformOutput | Get-Member -MemberType NoteProperty | % { $o = $terraformOutput.($_.Name); Write-Host "##vso[task.setvariable variable=$($_.Name);isoutput=true;issecret=$($o.sensitive)]$($o.value)" }
name: terraformOutput
displayName: Parse terraform outputs
workingDirectory: $(Pipeline.Workspace)/infra
# On déploie le site de documentation, avec le token récupéré ci-dessus
- task: AzureStaticWebApp@0
displayName: "Deploy Static Web App"
inputs:
workingDirectory: $(Pipeline.Workspace)/app
app_location: "."
api_location: "/api"
skip_app_build: true
skip_api_build: true
is_static_export: true
verbose: true
azure_static_web_apps_api_token: "$(terraformOutput.static_web_app_api_key)"
production_branch: main
Si vous avez un site qui n'est pas du pur HTML comme mon exemple, pensez à adapter la CI pour builder ce dernier également.
Tests
Dans notre exemple, j'ai préparé 4 chemins différents et 2 rôles (user et administrateur). Testons-les :
- / : accessible par les utilisateurs et administrateurs
- /admin : accessible par les administrateurs uniquement.
- /nested et /nested/nested : accessible par les utilisateurs et administrateurs. Les restrictions s'appliquent récursivement comme on peut le voir en allant sur cette page.
- /forbidden : inaccessible. On reçoit une 403 en allant sur cette page.
Une fois connecté, on peut voir les rôles qui nous sont assignés en se rendant sur la page votre_site/.auth/me.
Cela permet de voir que la correspondance entre nos rôles et nos groupes de l'AAD a fonctionné.
Conclusion
Notre site est désormais sécurisé via une authentification Azure Active Directory !
Nous pouvons également très simplement gérer nos autorisations et ajouter de nouveaux rôle personnalisés à nos utilisateurs avec une simple mise à jour de notre API getroles et de notre fichier de configuration staticwebapp.config.json.
Bien sûr, libre à vous de faire évoluer ce système pour l'adapter à vos besoins, mais cet exemple vous a montré les bases pour protéger par exemple le site de documentation interne de votre organisation.
Une dernière recommandation que je vous ferais est de déployer à l'intérieur d'un réseau privé votre Static Web App si votre site n'a pas vocation a être accédée depuis Internet. Un prochain article sera dédié à cela.