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