Skip to content

Commit d7753d9

Browse files
authored
Add Entra ID authentication (#39)
* Add Entra ID authentication * Extract token acquisition to service * Establish SignalR connection after user login * Reuse getAccessToken function * Cleanup * Change key vault deployment * Add env variables required for creating static assets
1 parent 1588842 commit d7753d9

27 files changed

Lines changed: 558 additions & 29 deletions

.github/workflows/app.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ on:
1111
jobs:
1212
build:
1313
runs-on: ubuntu-latest
14+
environment: Development
1415

1516
steps:
1617
- name: Checkout code
@@ -38,7 +39,9 @@ jobs:
3839
yarn build
3940
working-directory: src/app
4041
env:
41-
VITE_API_ROOT: https://app-projectestimate-api-dev.azurewebsites.net/api
42+
VITE_API_ROOT: ${{ secrets.VITE_API_ROOT }}
43+
VITE_AZURE_CLIENT_ID: ${{ secrets.VITE_AZURE_CLIENT_ID }}
44+
VITE_AZURE_AUTHORITY: ${{ secrets.VITE_AZURE_AUTHORITY }}
4245

4346
- name: Package artifact
4447
uses: actions/upload-artifact@v4

infra/resourceGroup.bicep

Lines changed: 130 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,15 @@ param apiWebAppName string = 'app-projectestimate-api-${env}'
4040
@description('Optional. The name of the Storage Account to create.')
4141
param storageAccountName string = 'stoprojectestimate${env}'
4242

43+
@description('Optional. The name of the Key Vault to create.')
44+
param keyVaultName string = 'kv-projectestimate-${env}'
45+
4346
@description('Optional. Indicates number fo days to retain deleted items (containers, blobs, snapshosts, versions). Default value is 7')
4447
param daysSoftDelete int = 7
4548

49+
@description('Optional. Enable Key Vault purge protection. Default is false.')
50+
param enableKeyVaultPurgeProtection bool = false
51+
4652
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
4753
name: logAnalyticsName
4854
location: location
@@ -78,6 +84,28 @@ resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
7884
}
7985
}
8086

87+
resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' = {
88+
name: keyVaultName
89+
location: location
90+
tags: tags
91+
properties: {
92+
sku: {
93+
family: 'A'
94+
name: 'standard'
95+
}
96+
tenantId: subscription().tenantId
97+
enableRbacAuthorization: true
98+
enableSoftDelete: true
99+
enablePurgeProtection: enableKeyVaultPurgeProtection ? true : null
100+
softDeleteRetentionInDays: 10
101+
publicNetworkAccess: 'Enabled'
102+
networkAcls: {
103+
defaultAction: 'Allow'
104+
bypass: 'AzureServices'
105+
}
106+
}
107+
}
108+
81109
module storageAccount 'storageAccount.bicep' = {
82110
name: storageAccountName
83111
params: {
@@ -116,6 +144,54 @@ resource documentIntelligence 'Microsoft.CognitiveServices/accounts@2024-10-01'
116144
}
117145
}
118146

147+
resource applicationInsightsConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = {
148+
parent: keyVault
149+
name: 'ApplicationInsights--ConnectionString'
150+
properties: {
151+
value: appInsights.properties.ConnectionString
152+
}
153+
}
154+
155+
resource storageConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
156+
parent: keyVault
157+
name: 'StorageAccount--ConnectionString'
158+
properties: {
159+
value: storageAccount.outputs.connectionString
160+
}
161+
}
162+
163+
resource openAiEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
164+
parent: keyVault
165+
name: 'Azure--OpenAI--Endpoint'
166+
properties: {
167+
value: openAIService.outputs.endpoint
168+
}
169+
}
170+
171+
resource openAiApiKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
172+
parent: keyVault
173+
name: 'Azure--OpenAI--ApiKey'
174+
properties: {
175+
value: openAIService.outputs.apiKey
176+
}
177+
}
178+
179+
resource documentIntelligenceEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
180+
parent: keyVault
181+
name: 'Azure--DocumentIntelligence--Endpoint'
182+
properties: {
183+
value: documentIntelligence.properties.endpoint
184+
}
185+
}
186+
187+
resource documentIntelligenceApiKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
188+
parent: keyVault
189+
name: 'Azure--DocumentIntelligence--ApiKey'
190+
properties: {
191+
value: documentIntelligence.listKeys().key1
192+
}
193+
}
194+
119195
resource appServicePlan 'Microsoft.Web/serverfarms@2024-04-01' = {
120196
name: appServicePlanName
121197
location: location
@@ -154,25 +230,72 @@ module apiWebApp 'webApp.bicep' = {
154230
clientAffinityEnabled: false
155231
httpsOnly: true
156232
kind: 'app,linux'
233+
useManagedIdentity: true
234+
}
235+
}
236+
237+
resource keyVaultRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
238+
scope: keyVault
239+
name: guid(keyVault.id, apiWebAppName, 'Key Vault Secrets User')
240+
properties: {
241+
roleDefinitionId: subscriptionResourceId(
242+
'Microsoft.Authorization/roleDefinitions',
243+
'4633458b-17de-408a-b874-0445c86b69e6'
244+
) // Key Vault Secrets User
245+
principalId: apiWebApp.outputs.principalId
246+
principalType: 'ServicePrincipal'
247+
}
248+
}
249+
250+
resource uiWebAppConfig 'Microsoft.Web/sites/config@2024-04-01' = {
251+
name: '${uiWebApp.name}/web'
252+
properties: {
253+
linuxFxVersion: 'NODE|22-lts'
157254
}
158255
}
159256

160257
resource apiWebAppConfig 'Microsoft.Web/sites/config@2024-04-01' = {
161258
name: '${apiWebAppName}/web'
162-
dependsOn: [apiWebApp]
259+
dependsOn: [apiWebApp, keyVaultRoleAssignment]
163260
properties: {
164261
linuxFxVersion: 'DOTNETCORE|9.0'
165262
cors: {
166263
allowedOrigins: [uiWebApp.outputs.endpoint]
167264
supportCredentials: true
168265
}
169266
appSettings: [
170-
{ name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsights.properties.ConnectionString }
171-
{ name: 'Azure__StorageAccount__ConnectionString', value: storageAccount.outputs.connectionString }
172-
{ name: 'Azure__OpenAI__Endpoint', value: openAIService.outputs.endpoint }
173-
{ name: 'Azure__OpenAI__ApiKey', value: openAIService.outputs.apiKey }
174-
{ name: 'Azure__DocumentIntelligence__Endpoint', value: documentIntelligence.properties.endpoint }
175-
{ name: 'Azure__DocumentIntelligence__ApiKey', value: documentIntelligence.listKeys().key1 }
267+
{
268+
name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
269+
value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=ApplicationInsights--ConnectionString)'
270+
}
271+
{
272+
name: 'Azure__StorageAccount__ConnectionString'
273+
value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=StorageAccount--ConnectionString)'
274+
}
275+
{
276+
name: 'Azure__OpenAI__Endpoint'
277+
value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=Azure--OpenAI--Endpoint)'
278+
}
279+
{
280+
name: 'Azure__OpenAI__ApiKey'
281+
value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=Azure--OpenAI--ApiKey)'
282+
}
283+
{
284+
name: 'Azure__DocumentIntelligence__Endpoint'
285+
value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=Azure--DocumentIntelligence--Endpoint)'
286+
}
287+
{
288+
name: 'Azure__DocumentIntelligence__ApiKey'
289+
value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=Azure--DocumentIntelligence--ApiKey)'
290+
}
291+
{
292+
name: 'Security__Authentication__Authority'
293+
value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=Security--Authentication--Authority)'
294+
}
295+
{
296+
name: 'Security__Authentication__Audience'
297+
value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=Security--Authentication--Audience)'
298+
}
176299
]
177300
}
178301
}

infra/webApp.bicep

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,16 @@ param alwaysOn bool = true
2626
@description(' Optional. Web app kind.')
2727
param kind string = 'app,linux'
2828

29+
@description('Optional. Enable system-assigned managed identity.')
30+
param useManagedIdentity bool = false
31+
2932
resource webApp 'Microsoft.Web/sites@2024-04-01' = {
3033
name: name
3134
location: location
3235
tags: tags
36+
identity: useManagedIdentity ? {
37+
type: 'SystemAssigned'
38+
} : null
3339
properties: {
3440
serverFarmId: appServicePlanId
3541
clientAffinityEnabled: clientAffinityEnabled
@@ -44,3 +50,4 @@ resource webApp 'Microsoft.Web/sites@2024-04-01' = {
4450
output endpoint string = 'https://${webApp.properties.defaultHostName}'
4551
output name string = webApp.name
4652
output id string = webApp.id
53+
output principalId string = useManagedIdentity ? webApp.identity.principalId : ''

src/api/Controllers/FileController.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Microsoft.AspNetCore.Mvc;
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
23
using ProjectEstimate.Application.Converters;
34
using ProjectEstimate.Application.Models;
45
using ProjectEstimate.Domain;
@@ -8,6 +9,7 @@ namespace ProjectEstimate.Controllers;
89

910
[Route("api/[controller]")]
1011
[ApiController]
12+
[Authorize]
1113
public class FileController : ControllerBase
1214
{
1315
private readonly ConsultantAgent _agent;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace ProjectEstimate.Extensions.Cors.Configuration;
2+
3+
public class CorsSettings
4+
{
5+
public const string SectionName = "Cors";
6+
public required string PolicyName { get; init; }
7+
public string[]? AllowedOrigins { get; init; }
8+
9+
public bool UseCors
10+
{
11+
get => AllowedOrigins is not null && AllowedOrigins.Length > 0;
12+
}
13+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using ProjectEstimate.Extensions.Cors.Configuration;
2+
using Microsoft.AspNetCore.Cors.Infrastructure;
3+
using Microsoft.Extensions.Options;
4+
5+
namespace ProjectEstimate.Extensions.Cors;
6+
7+
public class ConfigureCorsOptions : IPostConfigureOptions<CorsOptions>
8+
{
9+
private readonly IOptionsMonitor<CorsSettings> _options;
10+
11+
public ConfigureCorsOptions(IOptionsMonitor<CorsSettings> options)
12+
{
13+
_options = options;
14+
}
15+
16+
public void PostConfigure(string? name, CorsOptions options)
17+
{
18+
var configuration = _options.CurrentValue;
19+
if (!configuration.UseCors) return;
20+
if (configuration.AllowedOrigins is null) return;
21+
options.AddPolicy(
22+
configuration.PolicyName,
23+
policy => policy
24+
.WithOrigins(configuration.AllowedOrigins)
25+
.AllowAnyMethod()
26+
.AllowAnyHeader()
27+
.AllowCredentials());
28+
}
29+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using ProjectEstimate.Extensions.Cors.Configuration;
2+
using Microsoft.AspNetCore.Cors.Infrastructure;
3+
using Microsoft.Extensions.Options;
4+
5+
namespace ProjectEstimate.Extensions.Cors;
6+
7+
public static class CorsExtensions
8+
{
9+
public static IServiceCollection ConfigureCors(this IServiceCollection services)
10+
{
11+
services.AddOptions<CorsSettings>().BindConfiguration(CorsSettings.SectionName)
12+
.ValidateDataAnnotations()
13+
.ValidateOnStart();
14+
services.AddTransient<IPostConfigureOptions<CorsOptions>, ConfigureCorsOptions>();
15+
services.AddCors();
16+
return services;
17+
}
18+
19+
public static IApplicationBuilder UseCorsMiddleware(this IApplicationBuilder app)
20+
{
21+
var settings = app.ApplicationServices.GetRequiredService<IOptions<CorsSettings>>().Value;
22+
if (settings.UseCors)
23+
{
24+
app.UseCors(settings.PolicyName);
25+
}
26+
return app;
27+
}
28+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace ProjectEstimate.Extensions.Security.Configuration;
4+
5+
internal class AuthenticationConfiguration
6+
{
7+
[Required]
8+
public required string Authority { get; set; }
9+
10+
[Required]
11+
public required string Audience { get; set; }
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace ProjectEstimate.Extensions.Security.Configuration;
4+
5+
internal class SecurityConfiguration
6+
{
7+
public const string SectionName = "Security";
8+
9+
[Required]
10+
public required AuthenticationConfiguration Authentication { get; set; }
11+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using Microsoft.AspNetCore.Authentication;
2+
using Microsoft.AspNetCore.Authentication.JwtBearer;
3+
using Microsoft.Extensions.Options;
4+
5+
namespace ProjectEstimate.Extensions.Security;
6+
7+
public class ConfigureAuthenticationOptions : IConfigureOptions<AuthenticationOptions>
8+
{
9+
public void Configure(AuthenticationOptions options)
10+
{
11+
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
12+
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
13+
}
14+
}

0 commit comments

Comments
 (0)