Skip to content

Commit

Permalink
Added MicrosoftOAuth2HttpServiceClient adapter for SSO
Browse files Browse the repository at this point in the history
  • Loading branch information
jezzsantos committed Nov 25, 2024
1 parent 570b894 commit b4d2b4c
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 0 deletions.
6 changes: 6 additions & 0 deletions src/ApiHost1/appsettings.Azure.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
"DbCredentials": "",
"DbName": "SaaStack"
}
},
"MicrosoftIdentity": {
"ClientId": "",
"ClientSecret": "",
"BaseUrl": "https://localhost:5656/microsoftidentity",
"RedirectUri": "https://localhost:5001/sso/microsoft"
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides a <see cref="ISSOAuthenticationProvider" /> for handling Microsoft Identity SSO
/// </summary>
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<Result<SSOUserInfo, Error>> 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<Result<ProviderAuthenticationTokens, Error>> 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<SSOUserInfo, Error> ToSSoUserInfo(this List<AuthToken> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<ICallerContext> _caller;
private readonly Mock<IOAuth2Service> _oauth2Service;
private readonly MicrosoftOAuth2HttpServiceClient _serviceClient;

public MicrosoftOAuth2HttpServiceClientSpec()
{
_caller = new Mock<ICallerContext>();
_oauth2Service = new Mock<IOAuth2Service>();

_serviceClient = new MicrosoftOAuth2HttpServiceClient(_oauth2Service.Object);
}

[Fact]
public async Task WhenExchangeCodeForTokensAsync_ThenDelegates()
{
var tokens = new List<AuthToken>();
_oauth2Service.Setup(oas => oas.ExchangeCodeForTokensAsync(It.IsAny<ICallerContext>(),
It.IsAny<OAuth2CodeTokenExchangeOptions>(), It.IsAny<CancellationToken>()))
.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<CancellationToken>()));
}

[Fact]
public async Task WhenRefreshTokenAsync_ThenDelegates()
{
var tokens = new List<AuthToken>();
_oauth2Service.Setup(oas => oas.RefreshTokenAsync(It.IsAny<ICallerContext>(),
It.IsAny<OAuth2RefreshTokenOptions>(), It.IsAny<CancellationToken>()))
.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<CancellationToken>()));
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides a <see cref="IOAuth2Service" /> for accessing Microsoft Identity platform
/// <see
/// href="https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#redeem-a-code-for-an-access-token" />
/// </summary>
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<Result<List<AuthToken>, Error>> ExchangeCodeForTokensAsync(ICallerContext caller,
OAuth2CodeTokenExchangeOptions options, CancellationToken cancellationToken)
{
return await _oauth2Service.ExchangeCodeForTokensAsync(caller, options, cancellationToken);
}

public async Task<Result<List<AuthToken>, Error>> RefreshTokenAsync(ICallerContext caller,
OAuth2RefreshTokenOptions options, CancellationToken cancellationToken)
{
return await _oauth2Service.RefreshTokenAsync(caller, options, cancellationToken);
}
}

0 comments on commit b4d2b4c

Please sign in to comment.