-
-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added MicrosoftOAuth2HttpServiceClient adapter for SSO
- Loading branch information
1 parent
e1e3dd8
commit 74b50ba
Showing
4 changed files
with
244 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
128 changes: 128 additions & 0 deletions
128
src/IdentityInfrastructure/ApplicationServices/MicrosoftSSOAuthenticationProvider.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
62 changes: 62 additions & 0 deletions
62
...ure.Shared.UnitTests/ApplicationServices/External/MicrosoftOAuth2HttpServiceClientSpec.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>())); | ||
} | ||
} |
48 changes: 48 additions & 0 deletions
48
src/Infrastructure.Shared/ApplicationServices/External/MicrosoftOAuth2HttpServiceClient.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
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 OAuth2HttpServiceClient(recorder, | ||
Check failure on line 25 in src/Infrastructure.Shared/ApplicationServices/External/MicrosoftOAuth2HttpServiceClient.cs GitHub Actions / test
Check failure on line 25 in src/Infrastructure.Shared/ApplicationServices/External/MicrosoftOAuth2HttpServiceClient.cs GitHub Actions / test
Check failure on line 25 in src/Infrastructure.Shared/ApplicationServices/External/MicrosoftOAuth2HttpServiceClient.cs GitHub Actions / build
Check failure on line 25 in src/Infrastructure.Shared/ApplicationServices/External/MicrosoftOAuth2HttpServiceClient.cs GitHub Actions / build
|
||
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); | ||
} | ||
} |