diff --git a/src/ApiHost1/appsettings.Azure.json b/src/ApiHost1/appsettings.Azure.json
index 08520709..ea5716ec 100644
--- a/src/ApiHost1/appsettings.Azure.json
+++ b/src/ApiHost1/appsettings.Azure.json
@@ -24,6 +24,12 @@
"DbCredentials": "",
"DbName": "SaaStack"
}
+ },
+ "MicrosoftIdentity": {
+ "ClientId": "",
+ "ClientSecret": "",
+ "BaseUrl": "https://localhost:5656/microsoftidentity",
+ "RedirectUri": "https://localhost:5001/sso/microsoft"
}
}
}
diff --git a/src/IdentityInfrastructure/ApplicationServices/MicrosoftSSOAuthenticationProvider.cs b/src/IdentityInfrastructure/ApplicationServices/MicrosoftSSOAuthenticationProvider.cs
new file mode 100644
index 00000000..92c8c8e5
--- /dev/null
+++ b/src/IdentityInfrastructure/ApplicationServices/MicrosoftSSOAuthenticationProvider.cs
@@ -0,0 +1,128 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text.Json;
+using Application.Interfaces;
+using Application.Resources.Shared;
+using Application.Services.Shared;
+using Common;
+using Common.Configuration;
+using Common.Extensions;
+using IdentityApplication.ApplicationServices;
+using Infrastructure.Interfaces;
+using Infrastructure.Shared.ApplicationServices.External;
+
+namespace IdentityInfrastructure.ApplicationServices;
+
+///
+/// Provides a for handling Microsoft Identity SSO
+///
+public class MicrosoftSSOAuthenticationProvider : ISSOAuthenticationProvider
+{
+ public const string SSOName = "microsoft";
+ private const string ServiceName = "MicrosoftIdentityService";
+ private readonly IOAuth2Service _auth2Service;
+
+ public MicrosoftSSOAuthenticationProvider(IRecorder recorder, IHttpClientFactory clientFactory,
+ JsonSerializerOptions jsonOptions, IConfigurationSettings settings) : this(
+ new MicrosoftOAuth2HttpServiceClient(recorder, clientFactory, jsonOptions, settings))
+ {
+ }
+
+ private MicrosoftSSOAuthenticationProvider(IOAuth2Service auth2Service)
+ {
+ _auth2Service = auth2Service;
+ }
+
+ public async Task> AuthenticateAsync(ICallerContext caller, string authCode,
+ string? emailAddress, CancellationToken cancellationToken)
+ {
+ authCode.ThrowIfNotValuedParameter(nameof(authCode),
+ Resources.AnySSOAuthenticationProvider_MissingRefreshToken);
+
+ var retrievedTokens =
+ await _auth2Service.ExchangeCodeForTokensAsync(caller,
+ new OAuth2CodeTokenExchangeOptions(ServiceName, authCode), cancellationToken);
+ if (retrievedTokens.IsFailure)
+ {
+ return Error.NotAuthenticated();
+ }
+
+ var tokens = retrievedTokens.Value;
+ return tokens.ToSSoUserInfo();
+ }
+
+ public string ProviderName => SSOName;
+
+ public async Task> RefreshTokenAsync(ICallerContext caller,
+ string refreshToken, CancellationToken cancellationToken)
+ {
+ refreshToken.ThrowIfNotValuedParameter(nameof(refreshToken),
+ Resources.AnySSOAuthenticationProvider_MissingRefreshToken);
+
+ var retrievedTokens =
+ await _auth2Service.RefreshTokenAsync(caller,
+ new OAuth2RefreshTokenOptions(ServiceName, refreshToken),
+ cancellationToken);
+ if (retrievedTokens.IsFailure)
+ {
+ return Error.NotAuthenticated();
+ }
+
+ var tokens = retrievedTokens.Value;
+
+ var accessToken = tokens.Single(tok => tok.Type == TokenType.AccessToken);
+ var refreshedToken = tokens.FirstOrDefault(tok => tok.Type == TokenType.RefreshToken);
+ var idToken = tokens.FirstOrDefault(tok => tok.Type == TokenType.OtherToken);
+ return new ProviderAuthenticationTokens
+ {
+ Provider = SSOName,
+ AccessToken = new AuthenticationToken
+ {
+ ExpiresOn = accessToken.ExpiresOn,
+ Type = TokenType.AccessToken,
+ Value = accessToken.Value
+ },
+ RefreshToken = refreshedToken.Exists()
+ ? new AuthenticationToken
+ {
+ ExpiresOn = refreshedToken.ExpiresOn,
+ Type = TokenType.RefreshToken,
+ Value = refreshedToken.Value
+ }
+ : null,
+ OtherTokens = idToken.Exists()
+ ?
+ [
+ new AuthenticationToken
+ {
+ ExpiresOn = idToken.ExpiresOn,
+ Type = TokenType.OtherToken,
+ Value = idToken.Value
+ }
+ ]
+ : []
+ };
+ }
+}
+
+internal static class MicrosoftSSOAuthenticationProviderExtensions
+{
+ public static Result ToSSoUserInfo(this List tokens)
+ {
+ var idToken = tokens.FirstOrDefault(t => t.Type == TokenType.OtherToken);
+ if (idToken.NotExists())
+ {
+ return Error.NotAuthenticated();
+ }
+
+ var claims = new JwtSecurityTokenHandler().ReadJwtToken(idToken.Value).Claims.ToArray();
+ var emailAddress = claims.Single(c => c.Type == ClaimTypes.Email).Value;
+ var firstName = claims.Single(c => c.Type == ClaimTypes.GivenName).Value;
+ var lastName = claims.Single(c => c.Type == "family_name").Value;
+ var timezone =
+ Timezones.FindOrDefault(claims.Single(c => c.Type == AuthenticationConstants.Claims.ForTimezone).Value);
+ var country = CountryCodes.FindOrDefault(claims.Single(c => c.Type == ClaimTypes.Country).Value);
+
+ return new SSOUserInfo(tokens, emailAddress, firstName, lastName, timezone, country);
+ }
+}
\ No newline at end of file
diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/MicrosoftOAuth2HttpServiceClientSpec.cs b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/MicrosoftOAuth2HttpServiceClientSpec.cs
new file mode 100644
index 00000000..7bed1e02
--- /dev/null
+++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/MicrosoftOAuth2HttpServiceClientSpec.cs
@@ -0,0 +1,62 @@
+using Application.Interfaces;
+using Application.Resources.Shared;
+using Application.Services.Shared;
+using FluentAssertions;
+using Infrastructure.Shared.ApplicationServices.External;
+using Moq;
+using UnitTesting.Common;
+using Xunit;
+
+namespace Infrastructure.Shared.UnitTests.ApplicationServices.External;
+
+[Trait("Category", "Unit")]
+public class MicrosoftOAuth2HttpServiceClientSpec
+{
+ private readonly Mock _caller;
+ private readonly Mock _oauth2Service;
+ private readonly MicrosoftOAuth2HttpServiceClient _serviceClient;
+
+ public MicrosoftOAuth2HttpServiceClientSpec()
+ {
+ _caller = new Mock();
+ _oauth2Service = new Mock();
+
+ _serviceClient = new MicrosoftOAuth2HttpServiceClient(_oauth2Service.Object);
+ }
+
+ [Fact]
+ public async Task WhenExchangeCodeForTokensAsync_ThenDelegates()
+ {
+ var tokens = new List();
+ _oauth2Service.Setup(oas => oas.ExchangeCodeForTokensAsync(It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(tokens);
+ var options = new OAuth2CodeTokenExchangeOptions("aservicename", "acode");
+
+ var result = await _serviceClient.ExchangeCodeForTokensAsync(_caller.Object,
+ options, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Should().BeSameAs(tokens);
+ _oauth2Service.Verify(oas => oas.ExchangeCodeForTokensAsync(_caller.Object,
+ options, It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenRefreshTokenAsync_ThenDelegates()
+ {
+ var tokens = new List();
+ _oauth2Service.Setup(oas => oas.RefreshTokenAsync(It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(tokens);
+ var options = new OAuth2RefreshTokenOptions("aservicename", "arefreshtoken");
+
+ var result = await _serviceClient.RefreshTokenAsync(_caller.Object,
+ options, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Should().BeSameAs(tokens);
+ _oauth2Service.Verify(oas => oas.RefreshTokenAsync(_caller.Object,
+ options, It.IsAny()));
+ }
+}
\ No newline at end of file
diff --git a/src/Infrastructure.Shared/ApplicationServices/External/MicrosoftOAuth2HttpServiceClient.cs b/src/Infrastructure.Shared/ApplicationServices/External/MicrosoftOAuth2HttpServiceClient.cs
new file mode 100644
index 00000000..ef05e170
--- /dev/null
+++ b/src/Infrastructure.Shared/ApplicationServices/External/MicrosoftOAuth2HttpServiceClient.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+using Application.Interfaces;
+using Application.Resources.Shared;
+using Application.Services.Shared;
+using Common;
+using Common.Configuration;
+using Infrastructure.Web.Common.Clients;
+
+namespace Infrastructure.Shared.ApplicationServices.External;
+
+///
+/// Provides a for accessing Microsoft Identity platform
+///
+///
+public class MicrosoftOAuth2HttpServiceClient : IOAuth2Service
+{
+ private const string BaseUrlSettingName = "ApplicationServices:MicrosoftIdentity:BaseUrl";
+ private const string ClientIdSettingName = "ApplicationServices:MicrosoftIdentity:ClientId";
+ private const string ClientSecretSettingName = "ApplicationServices:MicrosoftIdentity:ClientSecret";
+ private const string RedirectUriSettingName = "ApplicationServices:MicrosoftIdentity:RedirectUri";
+ private readonly IOAuth2Service _oauth2Service;
+
+ public MicrosoftOAuth2HttpServiceClient(IRecorder recorder, IHttpClientFactory clientFactory,
+ JsonSerializerOptions jsonOptions, IConfigurationSettings settings) : this(new GenericOAuth2HttpServiceClient(
+ recorder,
+ new ApiServiceClient(clientFactory, jsonOptions, settings.GetString(BaseUrlSettingName)),
+ settings.GetString(ClientIdSettingName), settings.GetString(ClientSecretSettingName),
+ settings.GetString(RedirectUriSettingName)))
+ {
+ }
+
+ internal MicrosoftOAuth2HttpServiceClient(IOAuth2Service oauth2Service)
+ {
+ _oauth2Service = oauth2Service;
+ }
+
+ public async Task, Error>> ExchangeCodeForTokensAsync(ICallerContext caller,
+ OAuth2CodeTokenExchangeOptions options, CancellationToken cancellationToken)
+ {
+ return await _oauth2Service.ExchangeCodeForTokensAsync(caller, options, cancellationToken);
+ }
+
+ public async Task, Error>> RefreshTokenAsync(ICallerContext caller,
+ OAuth2RefreshTokenOptions options, CancellationToken cancellationToken)
+ {
+ return await _oauth2Service.RefreshTokenAsync(caller, options, cancellationToken);
+ }
+}
\ No newline at end of file