Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This template deploys the following resources:

The template creates an API Management service with an OAuth-protected API.
It also deploys three Entra ID app registrations using the [Microsoft Graph Bicep Extension](https://learn.microsoft.com/en-us/community/content/microsoft-graph-bicep-extension): one app registration that represents the APIs in API Management, one client with 'read' and 'write' permissions and one client with no API access (for testing authorization failures).

Additionally, Application Insights and Log Analytics Workspace are deployed for monitoring and logging purposes.
A Key Vault is also included to securely store client secrets for integration tests.

Expand Down
4 changes: 3 additions & 1 deletion demos/demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ Keep track of these values because you'll need them later.

7. In the left-hand menu, select `Certificates & secrets`.

8. Under `Client secrets`, click on `New client secret`.
8. Under `Client secrets`, click on `New client secret`.

> **Note:** There should already be a client secret present that was generated during deployment and stored in Key Vault. This secret is used by the integration tests. You can retrieve its value from Key Vault if needed, or create a new one.

9. Add a description and set an expiration period for the secret.

Expand Down
35 changes: 18 additions & 17 deletions hooks/postprovision-create-and-store-client-secrets.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -76,32 +76,33 @@ function Add-ClientSecretToKeyVault {
}

# Create client secret for the app registration
# Retry if the secret starts with '-' as this can cause issues with Key Vault storage
# See also https://github.com/Azure/azure-cli/issues/23016
Write-Host "Creating client secret for app registration '$AppId'"
$secretResult = az ad app credential reset `
--id $AppId `
--display-name $SecretDisplayName `
--query "password" `
--output tsv

if ($LASTEXITCODE -ne 0) {
throw "Failed to create client secret for app registration: $AppId"
}
do {
$secretResult = az ad app credential reset `
--id $AppId `
--display-name $SecretDisplayName `
--query "password" `
--output tsv

if ($LASTEXITCODE -ne 0) {
throw "Failed to create client secret for app registration: $AppId"
}

if ($secretResult.StartsWith('-')) {
Write-Host "Generated secret starts with '-', regenerating..."
}
} while ($secretResult.StartsWith('-'))

Write-Host "Client secret created successfully for app registration '$AppId'"

# Encode the client secret as base64 to ensure safe storage in Key Vault
# This prevents issues with special characters (e.g., secrets starting with '-') that could cause errors when storing the secret
# See also https://github.com/Azure/azure-cli/issues/23016
$secretBytes = [System.Text.Encoding]::UTF8.GetBytes($secretResult)
$base64Secret = [System.Convert]::ToBase64String($secretBytes)

# Store the client secret in Key Vault
Write-Host "Storing client secret '$SecretName' in Key Vault '$KeyVaultName'"
az keyvault secret set `
--vault-name $KeyVaultName `
--name $SecretName `
--value $base64Secret `
--tags encoding=base64 `
--value $secretResult `
--output none

if ($LASTEXITCODE -ne 0) {
Expand Down
Binary file modified images/deployed-resources.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified images/github-actions-workflow-summary.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 10 additions & 14 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ targetScope = 'subscription'
// Imports
//=============================================================================

import { getResourceName, getInstanceId } from './functions/naming-conventions.bicep'
import { getResourceName, generateInstanceId } from './functions/naming-conventions.bicep'
import { apiManagementSettingsType, appInsightsSettingsType } from './types/settings.bicep'

//=============================================================================
// Parameters
Expand All @@ -24,43 +25,38 @@ param location string
@description('The name of the environment to deploy to')
param environmentName string

@maxLength(5) // The maximum length of the storage account name and key vault name is 24 characters. To prevent errors the instance name should be short.
@description('The instance that will be added to the deployed resources names to make them unique. Will be generated if not provided.')
param instance string = ''

//=============================================================================
// Variables
//=============================================================================

// Determine the instance id based on the provided instance or by generating a new one
var instanceId = getInstanceId(environmentName, location, instance)
var instanceId string = generateInstanceId(environmentName, location)

var resourceGroupName = getResourceName('resourceGroup', environmentName, location, instanceId)
var resourceGroupName string = getResourceName('resourceGroup', environmentName, location, instanceId)

var apiManagementSettings = {
var apiManagementSettings apiManagementSettingsType = {
serviceName: getResourceName('apiManagement', environmentName, location, instanceId)
publisherName: 'admin@example.org'
publisherEmail: 'admin@example.org'
sku: 'Consumption'
appRegistrationName: getResourceName('appRegistration', environmentName, location, 'apim-${instanceId}')
appRegistrationIdentifierUri: 'api://${getResourceName('apiManagement', environmentName, location, instanceId)}'
}

var appInsightsSettings = {
var appInsightsSettings appInsightsSettingsType = {
appInsightsName: getResourceName('applicationInsights', environmentName, location, instanceId)
logAnalyticsWorkspaceName: getResourceName('logAnalyticsWorkspace', environmentName, location, instanceId)
retentionInDays: 30
}

var validClientAppRegistrationName = getResourceName('appRegistration', environmentName, location, 'validclient-${instanceId}')
var invalidClientAppRegistrationName = getResourceName('appRegistration', environmentName, location, 'invalidclient-${instanceId}')
var validClientAppRegistrationName string = getResourceName('appRegistration', environmentName, location, 'validclient-${instanceId}')
var invalidClientAppRegistrationName string = getResourceName('appRegistration', environmentName, location, 'invalidclient-${instanceId}')

var keyVaultName string = getResourceName('keyVault', environmentName, location, instanceId)

// Generate a unique ID for the azd environment so we can identity the Entra ID resources created for this environment
// The environment name is not unique enough as multiple environments can have the same name in different subscriptions, regions, etc.
var azdEnvironmentId string = getResourceName('azdEnvironment', environmentName, location, instanceId)

var tags = {
var tags { *: string } = {
'azd-env-name': environmentName
'azd-env-id': azdEnvironmentId
'azd-template': 'ronaldbosma/protect-apim-with-oauth'
Expand Down
7 changes: 6 additions & 1 deletion infra/modules/entra-id/apim-app-registration.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ param identifierUri string
// Variables
//=============================================================================

var appRoles = [
type appRoleType = {
name: string
description: string
}

var appRoles appRoleType[] = [
{
name: 'Sample.Read'
description: 'Sample read application role'
Expand Down
2 changes: 1 addition & 1 deletion infra/modules/entra-id/assign-app-roles.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func getAppRoleIdByValue(appRoles array, value string) string =>
// Variables
//=============================================================================

var rolesToAssign = [
var rolesToAssign string[] = [
'Sample.Read'
'Sample.Write'
]
Expand Down
35 changes: 28 additions & 7 deletions infra/modules/services/api-management.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,32 @@ param appInsightsName string
// Variables
//=============================================================================

var serviceTags = union(tags, {
var serviceTags { *: string } = union(tags, {
'azd-service-name': 'apim'
})

var publisherName string = 'admin@example.org'
var publisherEmail string = 'admin@example.org'

// This will disable the specified weak/insecure cipher suites (https://ciphersuite.info/)
var customProperties resourceInput<'Microsoft.ApiManagement/service@2024-05-01'>.properties.customProperties = {
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TripleDes168': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_128_CBC_SHA': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_256_CBC_SHA': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_128_CBC_SHA256': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_256_CBC_SHA256': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_128_GCM_SHA256': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA': 'False'
'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_256_GCM_SHA384': 'False'
}

//=============================================================================
// Existing resources
//=============================================================================
Expand All @@ -44,19 +66,18 @@ resource appInsights 'Microsoft.Insights/components@2020-02-02' existing = {
// Resources
//=============================================================================

// API Management - Consumption tier (see also: https://learn.microsoft.com/en-us/azure/api-management/quickstart-bicep?tabs=CLI)

resource apiManagementService 'Microsoft.ApiManagement/service@2024-06-01-preview' = {
name: apiManagementSettings.serviceName
location: location
tags: serviceTags
sku: {
name: 'Consumption'
capacity: 0
name: apiManagementSettings.sku
capacity: apiManagementSettings.sku == 'Consumption' ? 0 : 1
}
properties: {
publisherName: apiManagementSettings.publisherName
publisherEmail: apiManagementSettings.publisherEmail
publisherName: publisherName
publisherEmail: publisherEmail
customProperties: contains(apiManagementSettings.sku, 'Consumption') ? null : customProperties
}
identity: {
type: 'SystemAssigned'
Expand Down
15 changes: 9 additions & 6 deletions infra/types/settings.bicep
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
// API Management

@description('The SKU of the API Management service')
type apimSkuType = 'Consumption' | 'Developer' | 'Basic' | 'Standard' | 'Premium' | 'StandardV2' | 'BasicV2'

@description('The settings for the API Management service')
@export()
type apiManagementSettingsType = {
@description('The name of the API Management service')
serviceName: string

@description('The name of the owner of the API Management service')
publisherName: string

@description('The email address of the owner of the API Management service')
publisherEmail: string
@description('The SKU of the API Management service')
sku: apimSkuType

@description('The name of the API Management app registration in Entra ID')
appRegistrationName: string
Expand All @@ -22,6 +22,9 @@ type apiManagementSettingsType = {

// Application Insights

@description('Retention options for Application Insights')
type appInsightsRetentionInDaysType = 30 | 60 | 90 | 120 | 180 | 270 | 365 | 550 | 730

@description('The settings for the App Insights instance')
@export()
type appInsightsSettingsType = {
Expand All @@ -32,5 +35,5 @@ type appInsightsSettingsType = {
logAnalyticsWorkspaceName: string

@description('Retention in days of the logging')
retentionInDays: int
retentionInDays: appInsightsRetentionInDaysType
}
14 changes: 1 addition & 13 deletions tests/IntegrationTests/Clients/KeyVaultClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,12 @@ public KeyVaultClient(Uri keyVaultUri)

/// <summary>
/// Retrieves the value of a secret from Azure Key Vault asynchronously.
/// Automatically handles base64-encoded secrets by checking for an "encoding=base64" tag.
/// </summary>
/// <param name="secretName">The name of the secret to retrieve.</param>
/// <returns>The decoded value of the secret as a UTF-8 string.</returns>
/// <returns>The value of the secret.</returns>
public async Task<string> GetSecretValueAsync(string secretName)
{
var secret = await _secretClient.GetSecretAsync(secretName);

// Check if the secret has a tag indicating it's base64 encoded
// This allows storing binary data or special characters in Key Vault secrets
if (secret.Value.Properties.Tags.Any(tag => tag.Key == "encoding" && tag.Value == "base64"))
{
// Decode the base64 string and convert to UTF-8 text
var decodedBytes = Convert.FromBase64String(secret.Value.Value);
return System.Text.Encoding.UTF8.GetString(decodedBytes);
}

// Return the secret value as-is if no base64 encoding is indicated
return secret.Value.Value;
}
}
Loading