diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index d735c32..94aac43 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -20,6 +20,7 @@ jobs: Solution_Path: MTSC.sln Test_Project_Path: MTSC.UnitTests\MTSC.UnitTests.csproj Source_Project_Path: MTSC\MTSC.csproj + Oauth_Project_Path: MTSC.OAuth2\MTSC.OAuth2.csproj Actions_Allow_Unsecure_Commands: true steps: @@ -44,9 +45,12 @@ jobs: - name: Build MTSC project run: dotnet build MTSC -c $env:Configuration - - name: Package + - name: Package MTSC run: dotnet pack -c Release -o . $env:Source_Project_Path + - name: Package MTSC.OAuth2 + run: dotnet pack -c Release -o . $env:Oauth_Project_Path + - name: Publish run: dotnet nuget push *.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c8aeef5..aa03d0e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,6 +22,8 @@ jobs: Solution_Path: MTSC.sln Test_Project_Path: MTSC.UnitTests\MTSC.UnitTests.csproj Source_Project_Path: MTSC\MTSC.csproj + OAuth2Source_Project_Path: MTSC.OAuth2\MTSC.OAuth2.csproj + OAuth2Test_Project_Path: MTSC.OAuth2.Tests\MTSC.OAuth2.Tests.csproj Actions_Allow_Unsecure_Commands: true steps: @@ -33,7 +35,7 @@ jobs: - name: Install .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: '6.0.x' + dotnet-version: '6.x.x' - name: Setup MSBuild.exe uses: microsoft/setup-msbuild@v1.0.1 @@ -41,6 +43,9 @@ jobs: - name: Execute Unit Tests run: dotnet test $env:Test_Project_Path -v n --filter "TestCategory!=ServerTests" + - name: Execute OAuth2 Tests + run: dotnet test $env:OAuth2Test_Project_Path -v n + - name: Restore Project run: msbuild $env:Solution_Path /t:Restore /p:Configuration=$env:Configuration /p:RuntimeIdentifier=$env:RuntimeIdentifier env: diff --git a/MTSC.OAuth2.Tests/CertificateUtilities.cs b/MTSC.OAuth2.Tests/CertificateUtilities.cs new file mode 100644 index 0000000..6c0e9b5 --- /dev/null +++ b/MTSC.OAuth2.Tests/CertificateUtilities.cs @@ -0,0 +1,29 @@ +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace MTSC.OAuth2.Tests; + +public static class CertificateUtilities +{ + public static X509Certificate2 CreateNewSelfSignedCertificate() + { + using var parent = RSA.Create(4096); + using var rsa = RSA.Create(2048); + var parentReq = new CertificateRequest( + "CN=Experimental Issuing Authority", + parent, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + parentReq.CertificateExtensions.Add( + new X509BasicConstraintsExtension(true, false, 0, true)); + + parentReq.CertificateExtensions.Add( + new X509SubjectKeyIdentifierExtension(parentReq.PublicKey, false)); + + return parentReq.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-45), + DateTimeOffset.UtcNow.AddDays(365)); + } +} diff --git a/MTSC.OAuth2.Tests/MTSC.OAuth2.Tests.csproj b/MTSC.OAuth2.Tests/MTSC.OAuth2.Tests.csproj new file mode 100644 index 0000000..d994b26 --- /dev/null +++ b/MTSC.OAuth2.Tests/MTSC.OAuth2.Tests.csproj @@ -0,0 +1,27 @@ + + + + net6.0 + enable + + false + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/MTSC.OAuth2.Tests/Models/HttpClientMock.cs b/MTSC.OAuth2.Tests/Models/HttpClientMock.cs new file mode 100644 index 0000000..a88cd7a --- /dev/null +++ b/MTSC.OAuth2.Tests/Models/HttpClientMock.cs @@ -0,0 +1,33 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace MTSC.OAuth2.Tests.Models +{ + public sealed class HttpClientMock : HttpClient + { + public Func> SendingAsync { get;set; } + public Func Sending { get; set; } + + public override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (this.SendingAsync is not null) + { + return await this.SendingAsync(request); + } + + return new HttpResponseMessage(); + } + + public override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (this.Sending is not null) + { + return this.Sending(request); + } + + return new HttpResponseMessage(); + } + } +} diff --git a/MTSC.OAuth2.Tests/Models/ScopedHttpClientMock.cs b/MTSC.OAuth2.Tests/Models/ScopedHttpClientMock.cs new file mode 100644 index 0000000..254916c --- /dev/null +++ b/MTSC.OAuth2.Tests/Models/ScopedHttpClientMock.cs @@ -0,0 +1,177 @@ +using System; +using System.Http; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace MTSC.OAuth2.Tests.Models +{ + public sealed class ScopedHttpClientMock : IHttpClient + { + private readonly HttpClient httpClient = new(); + + public Uri BaseAddress { get; set; } + public HttpRequestHeaders DefaultRequestHeaders { get; } + public long MaxResponseContentBufferSize { get; set; } + public TimeSpan Timeout { get; set; } + + public event EventHandler EventEmitted; + + public void CancelPendingRequests() + { + throw new NotImplementedException(); + } + + public Task DeleteAsync(string requestUri) + { + throw new NotImplementedException(); + } + + public Task DeleteAsync(string requestUri, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task DeleteAsync(Uri requestUri) + { + throw new NotImplementedException(); + } + + public Task DeleteAsync(Uri requestUri, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetAsync(string requestUri) + { + throw new NotImplementedException(); + } + + public Task GetAsync(string requestUri, HttpCompletionOption completionOption) + { + throw new NotImplementedException(); + } + + public Task GetAsync(string requestUri, HttpCompletionOption completionOption, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetAsync(string requestUri, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetAsync(Uri requestUri) + { + throw new NotImplementedException(); + } + + public Task GetAsync(Uri requestUri, HttpCompletionOption completionOption) + { + throw new NotImplementedException(); + } + + public Task GetAsync(Uri requestUri, HttpCompletionOption completionOption, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetAsync(Uri requestUri, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetByteArrayAsync(string requestUri) + { + throw new NotImplementedException(); + } + + public Task GetByteArrayAsync(Uri requestUri) + { + throw new NotImplementedException(); + } + + public Task GetStreamAsync(string requestUri) + { + throw new NotImplementedException(); + } + + public Task GetStreamAsync(Uri requestUri) + { + throw new NotImplementedException(); + } + + public Task GetStringAsync(string requestUri) + { + throw new NotImplementedException(); + } + + public Task GetStringAsync(Uri requestUri) + { + throw new NotImplementedException(); + } + + public Task PostAsync(string requestUri, HttpContent content) + { + throw new NotImplementedException(); + } + + public Task PostAsync(string requestUri, HttpContent content, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task PostAsync(Uri requestUri, HttpContent content) + { + throw new NotImplementedException(); + } + + public Task PostAsync(Uri requestUri, HttpContent content, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task PutAsync(string requestUri, HttpContent content) + { + throw new NotImplementedException(); + } + + public Task PutAsync(string requestUri, HttpContent content, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task PutAsync(Uri requestUri, HttpContent content) + { + throw new NotImplementedException(); + } + + public Task PutAsync(Uri requestUri, HttpContent content, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task SendAsync(HttpRequestMessage request) + { + throw new NotImplementedException(); + } + + public Task SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption) + { + throw new NotImplementedException(); + } + + public Task SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} diff --git a/MTSC.OAuth2.Tests/UnitTests/AuthorizeAttributeTests.cs b/MTSC.OAuth2.Tests/UnitTests/AuthorizeAttributeTests.cs new file mode 100644 index 0000000..c65aaa5 --- /dev/null +++ b/MTSC.OAuth2.Tests/UnitTests/AuthorizeAttributeTests.cs @@ -0,0 +1,490 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using MTSC.Common.Http; +using MTSC.OAuth2.Attributes; +using MTSC.OAuth2.Authorization; +using MTSC.OAuth2.Models; +using MTSC.ServerSide; +using Slim; +using Slim.Exceptions; +using System; +using System.Extensions; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Threading.Tasks; + +namespace MTSC.OAuth2.Tests.UnitTests +{ + [TestClass] + public sealed class AuthorizeAttributeTests + { + private const string AccessTokenObject = "AccessTokenObject"; + private const string AuthorizationCodeValue = "someValue"; + private const string AuthorizationCodeKey = "code"; + private const string StateValue = "someState"; + private const string StateKey = "state"; + private const string MaxAgeAttribute = "Max-Age"; + private const string RedirectUri = "RedirectUri"; + private const string OAuthUri = "OAuthUri"; + private const string AccessTokenKey = "JsonWebTokenString"; + private const string AccessTokenValue = "SomeValueHere"; + + private readonly Mock> loggerMock = new(); + private readonly Mock authorizationProviderMock = new(); + private readonly AuthorizeAttribute authorizeAttribute; + + public AuthorizeAttributeTests() + { + this.authorizeAttribute = new AuthorizeAttribute(this.authorizationProviderMock.Object, this.loggerMock.Object); + } + + [TestMethod] + public void ParameterlessConstructor_DoesNotInject() + { + var container = new ServiceManager(); + container.RegisterSingleton(); + + var action = () => container.GetService(); + + action.Should().Throw(); + } + + [TestMethod] + public void Constructors_PreferresConstructorWithILogger() + { + var calledExpectedConstructor = false; + var container = new ServiceManager(); + container.RegisterSingleton(); + container.RegisterSingleton(sp => this.authorizationProviderMock.Object); + container.RegisterSingleton(sp => + { + calledExpectedConstructor = true; + return this.loggerMock.Object; + }); + container.RegisterSingleton(sp => new Server()); + + _ = container.GetService(); + + calledExpectedConstructor.Should().BeTrue(); + } + + [TestMethod] + public void Constructor1_AuthorizationProviderIsNull_ThrowsArgumentNullException() + { + var action = () => new AuthorizeAttribute(null, this.loggerMock.Object); + + action.Should().Throw(); + } + + [TestMethod] + public void Constructor1_ILoggerIsNull_ThrowsArgumentNullException() + { + var action = () => new AuthorizeAttribute(this.authorizationProviderMock.Object, null as ILogger); + + action.Should().Throw(); + } + + [TestMethod] + public void Constructor2_AuthorizationProviderIsNull_ThrowsArgumentNullException() + { + var action = () => new AuthorizeAttribute(null, new Server()); + + action.Should().Throw(); + } + + [TestMethod] + public void Constructor2_ServerIsNull_ThrowsArgumentNullException() + { + var action = () => new AuthorizeAttribute(this.authorizationProviderMock.Object, null as Server); + + action.Should().Throw(); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieExists_VerifiesAccessToken() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + httpRequest.Cookies.Add(new Cookie(AccessTokenKey, AccessTokenValue)); + this.authorizationProviderMock.Setup(u => u.VerifyAccessToken(AccessTokenValue)) + .ReturnsAsync(new TokenValidationResponse { IsValid = true }); + + await this.authorizeAttribute.HandleRequestAsync(routeContext); + + this.authorizationProviderMock.Verify(); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieExists_AcceptsRequest() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + httpRequest.Cookies.Add(new Cookie(AccessTokenKey, AccessTokenValue)); + this.authorizationProviderMock.Setup(u => u.VerifyAccessToken(AccessTokenValue)) + .ReturnsAsync(new TokenValidationResponse { IsValid = true }); + + var response = await this.authorizeAttribute.HandleRequestAsync(routeContext); + + response.Should().Be(RouteEnablerAsyncResponse.Accept); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieExists_SetsAccessTokenInResources() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + httpRequest.Cookies.Add(new Cookie(AccessTokenKey, AccessTokenValue)); + this.authorizationProviderMock.Setup(u => u.VerifyAccessToken(AccessTokenValue)) + .ReturnsAsync(new TokenValidationResponse { IsValid = true }); + + _ = await this.authorizeAttribute.HandleRequestAsync(routeContext); + + routeContext.Resources[AccessTokenKey].Should().Be(AccessTokenValue); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieExists_ReturnsError() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + httpRequest.Cookies.Add(new Cookie(AccessTokenKey, AccessTokenValue)); + this.authorizationProviderMock.Setup(u => u.VerifyAccessToken(AccessTokenValue)) + .ReturnsAsync(new TokenValidationResponse { IsValid = false }); + + var response = await this.authorizeAttribute.HandleRequestAsync(routeContext); + + response.Should().BeOfType(); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieExists_Redirects() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + httpRequest.Cookies.Add(new Cookie(AccessTokenKey, AccessTokenValue)); + this.authorizationProviderMock.Setup(u => u.VerifyAccessToken(AccessTokenValue)) + .ReturnsAsync(new TokenValidationResponse { IsValid = false }); + this.authorizationProviderMock.Setup(u => u.GetRedirectUri()) + .ReturnsAsync(RedirectUri); + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.Headers.ContainsHeader(HttpMessage.ResponseHeaders.Location).Should().BeTrue(); + response.Response.Headers[HttpMessage.ResponseHeaders.Location].Should().Be(RedirectUri); + response.Response.StatusCode.Should().Be(HttpMessage.StatusCodes.TemporaryRedirect); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieExists_ExpiresCookie() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + httpRequest.Cookies.Add(new Cookie(AccessTokenKey, AccessTokenValue)); + this.authorizationProviderMock.Setup(u => u.VerifyAccessToken(AccessTokenValue)) + .ReturnsAsync(new TokenValidationResponse { IsValid = false }); + this.authorizationProviderMock.Setup(u => u.GetRedirectUri()) + .ReturnsAsync(RedirectUri); + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.Cookies.First(c => c.Key == AccessTokenKey).Attributes.First(a => a.Key == MaxAgeAttribute).Value.Should().Be("-1"); + response.Response.Cookies.First(c => c.Key == AccessTokenKey).Value.Should().Be(AccessTokenValue); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_StateKeyMissing_RedirectsToOAuth() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.Headers.ContainsHeader(HttpMessage.ResponseHeaders.Location).Should().BeTrue(); + response.Response.Headers[HttpMessage.ResponseHeaders.Location].Should().Be(OAuthUri); + response.Response.StatusCode.Should().Be(HttpMessage.StatusCodes.TemporaryRedirect); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_StateKeyMissing_SetsStateCookie() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.Cookies.First(c => c.Key == StateKey).Value.Should().NotBeNullOrWhiteSpace(); + } + + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_RequestQueryMissing_RedirectsToOAuth() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + httpRequest.Cookies.Add(new Cookie(StateKey, StateValue)); + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.Headers.ContainsHeader(HttpMessage.ResponseHeaders.Location).Should().BeTrue(); + response.Response.Headers[HttpMessage.ResponseHeaders.Location].Should().Be(OAuthUri); + response.Response.StatusCode.Should().Be(HttpMessage.StatusCodes.TemporaryRedirect); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_RequestQueryMissing_SetsStateCookie() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + httpRequest.Cookies.Add(new Cookie(StateKey, StateValue)); + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.Cookies.First(c => c.Key == StateKey).Value.Should().NotBeNullOrWhiteSpace(); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_NoStateInQuery_ReturnsUnauthorized() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + httpRequest.Cookies.Add(new Cookie(StateKey, StateValue)); + httpRequest.RequestQuery = "someKey=someValue"; + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.StatusCode.Should().Be(HttpMessage.StatusCodes.Unauthorized); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_StatesDoNotMatch_ReturnsUnauthorized() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + httpRequest.Cookies.Add(new Cookie(StateKey, StateValue)); + httpRequest.RequestQuery = $"{StateKey}=someValue"; + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.StatusCode.Should().Be(HttpMessage.StatusCodes.Unauthorized); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_NoAuthCode_ReturnsUnauthorized() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + httpRequest.Cookies.Add(new Cookie(StateKey, StateValue)); + httpRequest.RequestQuery = $"{StateKey}={StateValue}"; + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.StatusCode.Should().Be(HttpMessage.StatusCodes.Unauthorized); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_WithAuthCode_RetrievesAccessToken() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + this.authorizationProviderMock.Setup(u => u.RetrieveAccessToken(AuthorizationCodeValue)) + .ReturnsAsync(Optional.None()); + httpRequest.Cookies.Add(new Cookie(StateKey, StateValue)); + httpRequest.RequestQuery = $"{StateKey}={StateValue}&{AuthorizationCodeKey}={AuthorizationCodeValue}"; + + _ = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + this.authorizationProviderMock.Verify(); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_WrongAuthCode_ReturnsUnauthorized() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + this.authorizationProviderMock.Setup(u => u.RetrieveAccessToken(AuthorizationCodeValue)) + .ReturnsAsync(Optional.None()); + httpRequest.Cookies.Add(new Cookie(StateKey, StateValue)); + httpRequest.RequestQuery = $"{StateKey}={StateValue}&{AuthorizationCodeKey}={AuthorizationCodeValue}"; + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.StatusCode.Should().Be(HttpMessage.StatusCodes.Unauthorized); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_WithAuthCode_RedirectsToRedirectUri() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + var jsonWebToken = new JsonWebToken(new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken())); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + this.authorizationProviderMock.Setup(u => u.GetRedirectUri()) + .ReturnsAsync(RedirectUri); + this.authorizationProviderMock.Setup(u => u.RetrieveAccessToken(AuthorizationCodeValue)) + .ReturnsAsync(jsonWebToken); + httpRequest.Cookies.Add(new Cookie(StateKey, StateValue)); + httpRequest.RequestQuery = $"{StateKey}={StateValue}&{AuthorizationCodeKey}={AuthorizationCodeValue}"; + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.StatusCode.Should().Be(HttpMessage.StatusCodes.TemporaryRedirect); + response.Response.Headers.ContainsHeader(HttpMessage.ResponseHeaders.Location).Should().BeTrue(); + response.Response.Headers[HttpMessage.ResponseHeaders.Location].Should().Be(RedirectUri); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_WithAuthCode_SetsAccessTokenInCookie() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + var jsonWebToken = new JsonWebToken(new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken())); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + this.authorizationProviderMock.Setup(u => u.GetRedirectUri()) + .ReturnsAsync(RedirectUri); + this.authorizationProviderMock.Setup(u => u.RetrieveAccessToken(AuthorizationCodeValue)) + .ReturnsAsync(jsonWebToken); + httpRequest.Cookies.Add(new Cookie(StateKey, StateValue)); + httpRequest.RequestQuery = $"{StateKey}={StateValue}&{AuthorizationCodeKey}={AuthorizationCodeValue}"; + + var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + response.Response.Cookies.First(c => c.Key == AccessTokenKey).Value.Should().NotBeNull(); + } + + [TestMethod] + public async Task HandleRequestAsync_AccessTokenCookieMissing_WithAuthCode_SetsRouteContextResources() + { + var httpRequest = new HttpRequest(); + var routeContext = new RouteContext( + server: null, + httpRequest, + clientData: null, + scopedServiceProvider: null, + urlValues: null); + var jsonWebToken = new JsonWebToken(new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken())); + this.authorizationProviderMock.Setup(u => u.GetOAuthUri(It.IsAny())) + .ReturnsAsync(OAuthUri); + this.authorizationProviderMock.Setup(u => u.GetRedirectUri()) + .ReturnsAsync(RedirectUri); + this.authorizationProviderMock.Setup(u => u.RetrieveAccessToken(AuthorizationCodeValue)) + .ReturnsAsync(jsonWebToken); + httpRequest.Cookies.Add(new Cookie(StateKey, StateValue)); + httpRequest.RequestQuery = $"{StateKey}={StateValue}&{AuthorizationCodeKey}={AuthorizationCodeValue}"; + + _ = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast(); + + routeContext.Resources.TryGetValue(AccessTokenKey, out var accessTokenString).Should().BeTrue(); + accessTokenString.Should().NotBeNull(); + routeContext.Resources.TryGetValue(AccessTokenObject, out var accessTokenObject).Should().BeTrue(); + accessTokenObject.Should().BeOfType(); + } + } +} diff --git a/MTSC.OAuth2.Tests/UnitTests/ClientCertificateAuthorizationProviderTests.cs b/MTSC.OAuth2.Tests/UnitTests/ClientCertificateAuthorizationProviderTests.cs new file mode 100644 index 0000000..c94a1d3 --- /dev/null +++ b/MTSC.OAuth2.Tests/UnitTests/ClientCertificateAuthorizationProviderTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MTSC.OAuth2.Authorization; +using MTSC.OAuth2.Models; +using MTSC.OAuth2.Tests.Models; +using Slim; +using System.Http; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +namespace MTSC.OAuth2.Tests.UnitTests; + +[TestClass] +public sealed class ClientCertificateAuthorizationProviderTests +{ + private const string AuthorizationCode = "AuthorizationCode"; + + private readonly ClientCertificateAuthorizationProvider provider; + private readonly AuthorizationOptions authorizationOptions; + private readonly ScopedHttpClientMock httpClientMock = new(); + private readonly HttpClientMock httpClientMock2 = new(); + + public ClientCertificateAuthorizationProviderTests() + { + this.authorizationOptions = new AuthorizationOptions + { + ClientCertificate = CertificateUtilities.CreateNewSelfSignedCertificate(), + OpenIdConfigurationUri = string.Empty, + OAuthUri = string.Empty, + AuthenticationMode = AuthorizationOptions.Authentication.ClientCertificate, + AuthTokenEndpoint = string.Empty, + ClientId = string.Empty, + ClientSecret = string.Empty, + RedirectUri = string.Empty, + Scopes = string.Empty + }; + + this.provider = new ClientCertificateAuthorizationProvider(this.httpClientMock2, this.authorizationOptions); + } + + [TestMethod] + public void Constructor_WithIHttpClient_AndOptions_Builds() + { + var container = new ServiceManager(); + container.RegisterSingleton(); + container.RegisterSingleton>(sp => this.httpClientMock); + container.RegisterSingleton(sp => this.authorizationOptions); + + var provider = container.GetService(); + + provider.Should().NotBeNull(); + } + + [TestMethod] + public void Constructor_WithHttpClient_AndOptions_Builds() + { + var container = new ServiceManager(); + container.RegisterSingleton(); + container.RegisterSingleton(sp => this.httpClientMock2); + container.RegisterSingleton(sp => this.authorizationOptions); + + var provider = container.GetService(); + + provider.Should().NotBeNull(); + } + + [TestMethod] + public void Constructor_WithOptions_Builds() + { + var container = new ServiceManager(); + container.RegisterSingleton(); + container.RegisterSingleton(sp => this.authorizationOptions); + + var provider = container.GetService(); + + provider.Should().NotBeNull(); + } + + [TestMethod] + public async Task RetrieveAccessToken_SetsClientCertificate() + { + this.httpClientMock2.SendingAsync = async (request) => + { + request.Content.Should().BeOfType(); + var contentString = await request.Content.As().ReadAsStringAsync(); + var content = contentString.Split('&'); + var clientAssertion = content.Where(s => s.StartsWith("client_assertion=")).FirstOrDefault(); + clientAssertion.Should().NotBeNull(); + + return new HttpResponseMessage() + { + StatusCode = System.Net.HttpStatusCode.BadRequest + }; + }; + + await this.provider.RetrieveAccessToken(AuthorizationCode); + } +} diff --git a/MTSC.OAuth2.Tests/UnitTests/ClientSecretAuthorizationProviderTests.cs b/MTSC.OAuth2.Tests/UnitTests/ClientSecretAuthorizationProviderTests.cs new file mode 100644 index 0000000..3d54ed3 --- /dev/null +++ b/MTSC.OAuth2.Tests/UnitTests/ClientSecretAuthorizationProviderTests.cs @@ -0,0 +1,101 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MTSC.OAuth2.Authorization; +using MTSC.OAuth2.Models; +using MTSC.OAuth2.Tests.Models; +using Slim; +using System.Http; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +namespace MTSC.OAuth2.Tests.UnitTests; + +[TestClass] +public sealed class ClientSecretAuthorizationProviderTests +{ + private const string AuthorizationCode = "AuthorizationCode"; + private const string ClientSecret = "clientSecret"; + + private readonly ClientSecretAuthorizationProvider provider; + private readonly AuthorizationOptions authorizationOptions; + private readonly ScopedHttpClientMock httpClientMock = new(); + private readonly HttpClientMock httpClientMock2 = new(); + + public ClientSecretAuthorizationProviderTests() + { + this.authorizationOptions = new AuthorizationOptions + { + ClientCertificate = CertificateUtilities.CreateNewSelfSignedCertificate(), + OpenIdConfigurationUri = string.Empty, + OAuthUri = string.Empty, + AuthenticationMode = AuthorizationOptions.Authentication.ClientSecret, + AuthTokenEndpoint = string.Empty, + ClientId = string.Empty, + ClientSecret = ClientSecret, + RedirectUri = string.Empty, + Scopes = string.Empty + }; + + this.provider = new ClientSecretAuthorizationProvider(this.authorizationOptions, this.httpClientMock2); + } + + [TestMethod] + public void Constructor_WithIHttpClient_AndOptions_Builds() + { + var container = new ServiceManager(); + container.RegisterSingleton(); + container.RegisterSingleton>(sp => this.httpClientMock); + container.RegisterSingleton(sp => this.authorizationOptions); + + var provider = container.GetService(); + + provider.Should().NotBeNull(); + } + + [TestMethod] + public void Constructor_WithHttpClient_AndOptions_Builds() + { + var container = new ServiceManager(); + container.RegisterSingleton(); + container.RegisterSingleton(sp => this.httpClientMock2); + container.RegisterSingleton(sp => this.authorizationOptions); + + var provider = container.GetService(); + + provider.Should().NotBeNull(); + } + + [TestMethod] + public void Constructor_WithOptions_Builds() + { + var container = new ServiceManager(); + container.RegisterSingleton(); + container.RegisterSingleton(sp => this.authorizationOptions); + + var provider = container.GetService(); + + provider.Should().NotBeNull(); + } + + [TestMethod] + public async Task RetrieveAccessToken_SetsClientSecret() + { + this.httpClientMock2.SendingAsync = async (request) => + { + request.Content.Should().BeOfType(); + var contentString = await request.Content.As().ReadAsStringAsync(); + var content = contentString.Split('&'); + var clientSecret = content.Where(s => s.StartsWith("client_secret=")).FirstOrDefault(); + clientSecret.Should().NotBeNull(); + clientSecret?.Replace("client_secret=", "").Should().Be(ClientSecret); + + return new HttpResponseMessage() + { + StatusCode = System.Net.HttpStatusCode.BadRequest + }; + }; + + await this.provider.RetrieveAccessToken(AuthorizationCode); + } +} diff --git a/MTSC.OAuth2/Attributes/AuthorizeAttribute.cs b/MTSC.OAuth2/Attributes/AuthorizeAttribute.cs new file mode 100644 index 0000000..d126a28 --- /dev/null +++ b/MTSC.OAuth2/Attributes/AuthorizeAttribute.cs @@ -0,0 +1,240 @@ +using Microsoft.Extensions.Logging; +using MTSC.Common.Http; +using MTSC.Common.Http.Attributes; +using MTSC.OAuth2.Models; +using System; +using System.Extensions; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Slim.Attributes; +using MTSC.ServerSide; +using MTSC.OAuth2.Authorization; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace MTSC.OAuth2.Attributes +{ + public sealed class AuthorizeAttribute : RouteFilterAttribute + { + private const string StateKey = "state"; + private const string AuthorizationCodeKey = "code"; + private const string MaxAgeAttribute = "Max-Age"; + private const string AccessTokenObject = "AccessTokenObject"; + + public const string AccessTokenKey = "JsonWebTokenString"; + public const string AccessTokenObjectKey = "JsonWebToken"; + + private static readonly RandomNumberGenerator RandomNumberGenerator = RandomNumberGenerator.Create(); + + private readonly ILogger logger; + private readonly IAuthorizationProvider authorizationProvider; + + [DoNotInject] + /// + /// Public parameterless constructor to be used for decorating endpoints. Do not use to instantiate new . + /// Use instead any of the other constructors. + /// + public AuthorizeAttribute() + { + } + + [PreferredConstructor(Priority = 0)] + public AuthorizeAttribute( + IAuthorizationProvider authorizationProvider, + ILogger logger) + { + this.authorizationProvider = authorizationProvider.ThrowIfNull(nameof(authorizationProvider)); + this.logger = logger.ThrowIfNull(nameof(logger)); + } + + [PreferredConstructor(Priority = 1)] + public AuthorizeAttribute( + IAuthorizationProvider authorizationProvider, + Server server) + { + this.authorizationProvider = authorizationProvider.ThrowIfNull(nameof(authorizationProvider)); + this.logger = new ServerDebugLogger(server.ThrowIfNull(nameof(server))); + } + + public override async Task HandleRequestAsync(RouteContext routeContext) + { + this.logger.LogInformation("Authorizing request"); + if (TryGetCookieValue(routeContext.HttpRequest, AccessTokenKey, out var accessToken)) + { + this.logger.LogInformation("Found authorization token. Validating"); + var validationResponse = await this.authorizationProvider.VerifyAccessToken(accessToken); + if (validationResponse.IsValid is false) + { + this.logger.LogInformation("Validation failed"); + return await this.ExpireAccessTokenAndRedirect(accessToken); + } + + this.logger.LogInformation("Validation succeeded"); + routeContext.Resources[AccessTokenKey] = accessToken; + routeContext.Resources[AccessTokenObjectKey] = validationResponse.JsonWebToken; + return RouteEnablerAsyncResponse.Accept; + } + + if (TryGetCookieValue(routeContext.HttpRequest, StateKey, out var state) && + TryParseRequestQuery(routeContext.HttpRequest.RequestQuery, out var queryDictionary)) + { + this.logger.LogInformation("Received redirect from OAuth server. Checking state value"); + if (queryDictionary.TryGetValue(StateKey, out var redirectState) is false) + { + this.logger.LogInformation("No state value found in query. Rejecting request"); + return RouteEnablerAsyncResponse.Error(Unauthorized401); + } + + if (state != redirectState) + { + this.logger.LogInformation("State values do not match. Rejecting request"); + return RouteEnablerAsyncResponse.Error(Unauthorized401); + } + + if (queryDictionary.TryGetValue(AuthorizationCodeKey, out var authCode) is false) + { + this.logger.LogInformation("No authorization code found in the query. Rejecting request"); + return RouteEnablerAsyncResponse.Error(Unauthorized401); + } + + this.logger.LogInformation("Requesting access token"); + var maybeAccessToken = await this.authorizationProvider.RetrieveAccessToken(authCode); + var retrievedAccessToken = maybeAccessToken.ExtractValue(); + if (retrievedAccessToken is null) + { + this.logger.LogInformation("Failed to retrieve access token. Rejecting request"); + return RouteEnablerAsyncResponse.Error(Unauthorized401); + } + + routeContext.Resources[AccessTokenKey] = retrievedAccessToken.EncodedToken; + routeContext.Resources[AccessTokenObject] = retrievedAccessToken; + return await this.RedirectToUri(retrievedAccessToken); + } + + return await this.RedirectWithStateToOAuth(); + } + public override void HandleResponse(RouteContext routeContext) + { + SetAccessTokenCookieInContext(routeContext); + } + + private async Task RedirectWithStateToOAuth() + { + var state = GetRandomState(); + this.logger.LogDebug($"Redirecting client to OAuth server with state {state}"); + return RouteEnablerAsyncResponse.Error(await this.OAuthRedirect307(state)); + } + private async Task RedirectToUri(JsonWebToken accessTokenObject) + { + this.logger.LogDebug($"Redirecting to redirect uri"); + return RouteEnablerAsyncResponse.Error(await this.RedirectToUri307(accessTokenObject)); + } + private async Task ExpireAccessTokenAndRedirect(string accessToken) + { + this.logger.LogDebug($"Expiring access token and redirecting"); + return RouteEnablerAsyncResponse.Error(await this.ExpireAccessAndRedirect307(accessToken)); + } + private async Task OAuthRedirect307(string state) + { + var response = new HttpResponse + { + StatusCode = HttpMessage.StatusCodes.TemporaryRedirect + }; + + var oAuthUri = await this.authorizationProvider.GetOAuthUri(state); + response.Headers.AddHeader(HttpMessage.ResponseHeaders.Location, oAuthUri); + response.Cookies.Add(new Cookie(StateKey, state)); + return response; + } + private async Task RedirectToUri307(JsonWebToken accessTokenObject) + { + var response = new HttpResponse + { + StatusCode = HttpMessage.StatusCodes.TemporaryRedirect + }; + + response.Headers.AddHeader(HttpMessage.ResponseHeaders.Location, await this.authorizationProvider.GetRedirectUri()); + SetAccessTokenCookieInHttpResponse(response, accessTokenObject); + return response; + } + private async Task ExpireAccessAndRedirect307(string accessToken) + { + var response = new HttpResponse + { + StatusCode = HttpMessage.StatusCodes.TemporaryRedirect + }; + + response.Headers.AddHeader(HttpMessage.ResponseHeaders.Location, await this.authorizationProvider.GetRedirectUri()); + ResetAccessTokenCookieInHttpResponse(response, accessToken); + return response; + } + + private static void SetAccessTokenCookieInContext(RouteContext routeContext) + { + if (routeContext.Resources.TryGetValue(AccessTokenObject, out var accessToken) is false || + accessToken is not JsonWebToken accessTokenObject) + { + return; + } + + SetAccessTokenCookieInHttpResponse(routeContext.HttpResponse, accessTokenObject); + } + private static void SetAccessTokenCookieInHttpResponse(HttpResponse response, JsonWebToken jsonWebToken) + { + var cookie = new Cookie(AccessTokenKey, jsonWebToken.EncodedToken); + cookie.Attributes.Add(MaxAgeAttribute, (jsonWebToken.ValidTo - DateTime.Now).TotalSeconds.ToString()); + response?.Cookies?.Add(cookie); + } + private static void ResetAccessTokenCookieInHttpResponse(HttpResponse response, string accessToken) + { + var cookie = new Cookie(AccessTokenKey, accessToken); + cookie.Attributes.Add(MaxAgeAttribute, "-1"); + response?.Cookies?.Add(cookie); + } + private static bool TryParseRequestQuery(string query, out Dictionary queryValues) + { + queryValues = null; + if (string.IsNullOrEmpty(query)) + { + return false; + } + + try + { + queryValues = query.Split('&') + .ToDictionary(c => c.Split('=')[0].ToLowerInvariant(), c => Uri.UnescapeDataString(c.Split('=')[1])); + return true; + } + catch(Exception) + { + return false; + } + } + private static bool TryGetCookieValue(HttpRequest httpRequest, string cookieKey, out string cookieValue) + { + var cookie = httpRequest.Cookies.FirstOrDefault(c => c.Key == cookieKey); + cookieValue = cookie?.Value; + if (string.IsNullOrWhiteSpace(cookieValue)) + { + return false; + } + + return true; + } + private static string GetRandomState() + { + var bytes = new byte[64]; + RandomNumberGenerator.GetNonZeroBytes(bytes); + return Convert.ToBase64String(bytes) + .Replace("=", "") + .Replace('+', '-') + .Replace('/', '_'); + } + private static HttpResponse Unauthorized401 => new() + { + StatusCode = HttpMessage.StatusCodes.Unauthorized, + BodyString = "Authorization failed" + }; + } +} diff --git a/MTSC.OAuth2/Authorization/ClientCertificateAuthorizationProvider.cs b/MTSC.OAuth2/Authorization/ClientCertificateAuthorizationProvider.cs new file mode 100644 index 0000000..03aeb5a --- /dev/null +++ b/MTSC.OAuth2/Authorization/ClientCertificateAuthorizationProvider.cs @@ -0,0 +1,79 @@ +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using MTSC.OAuth2.Models; +using Slim.Attributes; +using System; +using System.Collections.Generic; +using System.Extensions; +using System.Http; +using System.Net.Http; +using System.Security.Cryptography.X509Certificates; + +namespace MTSC.OAuth2.Authorization +{ + internal sealed class ClientCertificateAuthorizationProvider : FormAuthorizationProvider + { + private readonly AuthorizationOptions authorizationOptions; + + [PreferredConstructor(Priority = 0)] + public ClientCertificateAuthorizationProvider( + IHttpClient httpClient, + AuthorizationOptions authorizationOptions) + : base(httpClient, authorizationOptions) + { + this.authorizationOptions = authorizationOptions.ThrowIfNull(nameof(authorizationOptions)); + this.authorizationOptions.ClientCertificate.ThrowIfNull(nameof(this.authorizationOptions.ClientCertificate)); + } + + [PreferredConstructor(Priority = 1)] + public ClientCertificateAuthorizationProvider( + HttpClient httpClient, + AuthorizationOptions authorizationOptions) + : base(httpClient, authorizationOptions) + { + this.authorizationOptions = authorizationOptions.ThrowIfNull(nameof(authorizationOptions)); + this.authorizationOptions.ClientCertificate.ThrowIfNull(nameof(this.authorizationOptions.ClientCertificate)); + } + + [PreferredConstructor(Priority = 2)] + public ClientCertificateAuthorizationProvider( + AuthorizationOptions authorizationOptions) + : base(new HttpClient(), authorizationOptions) + { + this.authorizationOptions = authorizationOptions.ThrowIfNull(nameof(authorizationOptions)); + this.authorizationOptions.ClientCertificate.ThrowIfNull(nameof(this.authorizationOptions.ClientCertificate)); + } + + protected override IEnumerable> GetAdditionalFormFields() + { + var claims = this.GetClaims(); + var securityTokenDescriptor = new SecurityTokenDescriptor + { + Claims = claims, + SigningCredentials = new X509SigningCredentials(this.authorizationOptions.ClientCertificate) + }; + + var handler = new JsonWebTokenHandler(); + var signedClientAssertion = handler.CreateToken(securityTokenDescriptor); + + return new Dictionary + { + { "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" }, + { "client_assertion", signedClientAssertion } + }; + } + + private Dictionary GetClaims() + { + return new Dictionary() + { + { "aud", $"{this.authorizationOptions.OAuthUri}/{this.authorizationOptions.AuthTokenEndpoint}" }, + { "exp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 30 }, + { "iss", this.authorizationOptions.ClientId }, + { "jti", Guid.NewGuid().ToString() }, + { "nbf", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }, + { "sub", this.authorizationOptions.ClientId } + }; + } + } +} diff --git a/MTSC.OAuth2/Authorization/ClientSecretAuthorizationProvider.cs b/MTSC.OAuth2/Authorization/ClientSecretAuthorizationProvider.cs new file mode 100644 index 0000000..3f9bf38 --- /dev/null +++ b/MTSC.OAuth2/Authorization/ClientSecretAuthorizationProvider.cs @@ -0,0 +1,42 @@ +using MTSC.OAuth2.Models; +using Slim.Attributes; +using System.Collections.Generic; +using System.Http; +using System.Net.Http; + +namespace MTSC.OAuth2.Authorization +{ + internal sealed class ClientSecretAuthorizationProvider : FormAuthorizationProvider + { + [PreferredConstructor(Priority = 0)] + public ClientSecretAuthorizationProvider( + AuthorizationOptions options, + IHttpClient httpClient) + : base(httpClient, options) + { + } + + [PreferredConstructor(Priority = 1)] + public ClientSecretAuthorizationProvider( + AuthorizationOptions options, + HttpClient httpClient) + : base(httpClient, options) + { + } + + [PreferredConstructor(Priority = 2)] + public ClientSecretAuthorizationProvider( + AuthorizationOptions options) + : base(new HttpClient(), options) + { + } + + protected override IEnumerable> GetAdditionalFormFields() + { + return new List> + { + new KeyValuePair("client_secret", this.Options.ClientSecret) + }; + } + } +} diff --git a/MTSC.OAuth2/Authorization/FormAuthorizationProvider.cs b/MTSC.OAuth2/Authorization/FormAuthorizationProvider.cs new file mode 100644 index 0000000..70763f9 --- /dev/null +++ b/MTSC.OAuth2/Authorization/FormAuthorizationProvider.cs @@ -0,0 +1,104 @@ +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using MTSC.OAuth2.Models; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Extensions; +using System.Http; +using System.Net.Http; +using System.Threading.Tasks; + +namespace MTSC.OAuth2.Authorization +{ + internal abstract class FormAuthorizationProvider : IAuthorizationProvider + { + private const string RedirectUri = "{SERVERPLACEHOLDER}/authorize?response_type=code&client_id={CLIENTID}&redirect_uri={REDIRECTURI}&scope={SCOPE}&state={STATE}"; + private const string PostAuthCodeUri = "{SERVERPLACEHOLDER}/{TOKEN}"; + private const string ClientIdPlaceholder = "{CLIENTID}"; + private const string RedirectUriPlaceholder = "{REDIRECTURI}"; + private const string ScopePlaceholder = "{SCOPE}"; + private const string StatePlaceholder = "{STATE}"; + private const string ServerPlaceholder = "{SERVERPLACEHOLDER}"; + private const string TokenPlaceholder = "{TOKEN}"; + + private readonly AccessTokenValidator accessTokenValidator; + + protected AuthorizationHttpClientWrapper AuthorizationClientWrapper { get; set; } + protected AuthorizationOptions Options { get; set; } + + public FormAuthorizationProvider( + IHttpClient httpClient, + AuthorizationOptions options) + { + this.AuthorizationClientWrapper = new AuthorizationHttpClientWrapper(httpClient); + this.Options = options ?? throw new ArgumentNullException(nameof(options)); + this.accessTokenValidator = new AccessTokenValidator(this.AuthorizationClientWrapper, this.Options.ClientId, this.Options.OpenIdConfigurationUri); + } + + public FormAuthorizationProvider( + HttpClient httpClient, + AuthorizationOptions options) + { + this.AuthorizationClientWrapper = new AuthorizationHttpClientWrapper(httpClient); + this.Options = options ?? throw new ArgumentNullException(nameof(options)); + this.accessTokenValidator = new AccessTokenValidator(this.AuthorizationClientWrapper, this.Options.ClientId, this.Options.OpenIdConfigurationUri); + } + + public async Task VerifyAccessToken(string accessToken) + { + var validationResult = await this.accessTokenValidator.ValidateAccessToken(accessToken); + return validationResult.Switch( + onSuccess: result => result, + onFailure: exception => + { + return new TokenValidationResponse { IsValid = false }; + }); + } + + public Task GetOAuthUri(string state) + { + return Task.FromResult(RedirectUri + .Replace(ServerPlaceholder, this.Options.OAuthUri) + .Replace(RedirectUriPlaceholder, this.Options.RedirectUri) + .Replace(ClientIdPlaceholder, this.Options.ClientId) + .Replace(ScopePlaceholder, this.Options.Scopes) + .Replace(StatePlaceholder, state)); + } + + public async Task> RetrieveAccessToken( + string authorizationCode) + { + using var formContent = new FormUrlEncodedContent( + new Dictionary + { + { "grant_type", "authorization_code" }, + { "code", authorizationCode }, + { "redirect_uri", this.Options.RedirectUri }, + { "client_id", this.Options.ClientId }, + }.AddRange(GetAdditionalFormFields())); + + var response = await this.AuthorizationClientWrapper.PostAsync( + PostAuthCodeUri + .Replace(ServerPlaceholder, this.Options.OAuthUri) + .Replace(TokenPlaceholder, this.Options.AuthTokenEndpoint), formContent); + if (response.IsSuccessStatusCode is false) + { + return Optional.None(); + } + + var accessToken = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + var jsonWebTokenHandler = new JsonWebTokenHandler(); + var valid = await jsonWebTokenHandler.ValidateTokenAsync(accessToken.AccessToken, new TokenValidationParameters()); + var token = jsonWebTokenHandler.ReadJsonWebToken(accessToken.AccessToken); + return token; + } + + public Task GetRedirectUri() + { + return Task.FromResult(this.Options.RedirectUri); + } + + protected virtual IEnumerable> GetAdditionalFormFields() { return Array.Empty>(); } + } +} diff --git a/MTSC.OAuth2/Authorization/IAuthorizationProvider.cs b/MTSC.OAuth2/Authorization/IAuthorizationProvider.cs new file mode 100644 index 0000000..d24b94c --- /dev/null +++ b/MTSC.OAuth2/Authorization/IAuthorizationProvider.cs @@ -0,0 +1,15 @@ +using Microsoft.IdentityModel.JsonWebTokens; +using MTSC.OAuth2.Models; +using System.Extensions; +using System.Threading.Tasks; + +namespace MTSC.OAuth2.Authorization +{ + public interface IAuthorizationProvider + { + Task> RetrieveAccessToken(string authorizationCode); + Task VerifyAccessToken(string accessToken); + Task GetOAuthUri(string state); + Task GetRedirectUri(); + } +} diff --git a/MTSC.OAuth2/Builders/MicrosoftGraphAuthorizationBuilder.cs b/MTSC.OAuth2/Builders/MicrosoftGraphAuthorizationBuilder.cs new file mode 100644 index 0000000..c6ac156 --- /dev/null +++ b/MTSC.OAuth2/Builders/MicrosoftGraphAuthorizationBuilder.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.Options; +using MTSC.OAuth2.Authorization; +using MTSC.OAuth2.Models; +using MTSC.ServerSide; +using System; +using System.Extensions; +using System.Security.Cryptography.X509Certificates; +using static MTSC.OAuth2.Models.AuthorizationOptions; + +namespace MTSC.OAuth2.Builders +{ + public sealed class MicrosoftGraphAuthorizationBuilder + { + private const string AuthTokenEndpoint = "token"; + private const string TenantPlaceholder = "[TENANT]"; + private const string OAuthUri = "https://login.microsoftonline.com/[TENANT]/oauth2/v2.0"; + private const string OpenIdConfigurationUri = "https://login.microsoftonline.com/[TENANT]/.well-known/openid-configuration"; + + private readonly Server chainedServer; + private string Tenant { get; set; } + private string ClientId { get; set; } + private string Scopes { get; set; } + private Authentication AuthenticationMode { get; set; } + private string ClientSecret { get; set; } + private string ClientCertificateThumbprint { get; set; } + private X509Certificate2 ClientCertificate { get; set; } + private string RedirectUri { get; set; } + + internal MicrosoftGraphAuthorizationBuilder(Server chainedServer) + { + this.chainedServer = chainedServer.ThrowIfNull(nameof(chainedServer)); + } + + public MicrosoftGraphAuthorizationBuilder WithTenant(string tenant) + { + this.Tenant = tenant.ThrowIfNull(nameof(tenant)); + return this; + } + + public MicrosoftGraphAuthorizationBuilder WithClientId(string clientId) + { + this.ClientId = clientId.ThrowIfNull(nameof(clientId)); + return this; + } + + public MicrosoftGraphAuthorizationBuilder WithScopes(string scopes) + { + this.Scopes = scopes.ThrowIfNull(nameof(scopes)); + return this; + } + + public MicrosoftGraphAuthorizationBuilder WithRedirectUri(string redirectUri) + { + this.RedirectUri = redirectUri.ThrowIfNull(nameof(redirectUri)); + return this; + } + + public MicrosoftGraphAuthorizationBuilder WithClientSecret(string clientSecret) + { + this.ClientSecret = clientSecret.ThrowIfNull(nameof(clientSecret)); + this.AuthenticationMode = Authentication.ClientSecret; + return this; + } + + public MicrosoftGraphAuthorizationBuilder WithClientCertificateThumbprint(string clientCertificateThumbprint) + { + this.ClientCertificateThumbprint = clientCertificateThumbprint.ThrowIfNull(nameof(clientCertificateThumbprint)); + this.AuthenticationMode = Authentication.ClientCertificate; + return this; + } + + public MicrosoftGraphAuthorizationBuilder WithClientCertificate(X509Certificate2 x509Certificate2) + { + this.ClientCertificate = x509Certificate2.ThrowIfNull(nameof(x509Certificate2)); + if (this.ClientCertificate.HasPrivateKey is false) + { + throw new InvalidOperationException("Client certificate must have a private key"); + } + + this.AuthenticationMode = Authentication.ClientCertificate; + return this; + } + + public Server Build() + { + this.Tenant.ThrowIfNull(nameof(this.Tenant)); + this.ClientId.ThrowIfNull(nameof(this.Tenant)); + this.Scopes.ThrowIfNull(nameof(this.Tenant)); + this.RedirectUri.ThrowIfNull(nameof(this.RedirectUri)); + var oauthUri = OAuthUri.Replace(TenantPlaceholder, this.Tenant); + var openIdConfigurationUri = OpenIdConfigurationUri.Replace(TenantPlaceholder, this.Tenant); + var options = new AuthorizationOptions + { + ClientId = this.ClientId, + Scopes = this.Scopes, + OpenIdConfigurationUri = openIdConfigurationUri, + OAuthUri = oauthUri, + RedirectUri = this.RedirectUri, + AuthTokenEndpoint = AuthTokenEndpoint + }; + + if (this.AuthenticationMode == Authentication.ClientSecret && + this.ClientSecret.ThrowIfNull(nameof(this.ClientSecret)) is not null) + { + options.ClientSecret = this.ClientSecret; + options.AuthenticationMode = Authentication.ClientSecret; + this.chainedServer.ServiceManager.RegisterScoped(); + } + else if (this.ClientCertificate is not null) + { + options.ClientCertificate = this.ClientCertificate; + options.AuthenticationMode = Authentication.ClientCertificate; + this.chainedServer.ServiceManager.RegisterScoped(); + } + else if (this.ClientCertificateThumbprint.ThrowIfNull(nameof(this.ClientCertificateThumbprint)) is not null) + { + options.ClientCertificate = GetClientCertificate(this.ClientCertificateThumbprint); + options.AuthenticationMode = Authentication.ClientCertificate; + this.chainedServer.ServiceManager.RegisterScoped(); + } + + this.chainedServer.ServiceManager.RegisterSingleton(sp => options); + return this.chainedServer; + } + + private static X509Certificate2 GetClientCertificate(string thumbprint) + { + using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine); + store.Open(OpenFlags.ReadOnly); + var results = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, true); + return results.Count > 0 ? + results[0] : + null; + } + } +} diff --git a/MTSC.OAuth2/Exceptions/TokenValidationException.cs b/MTSC.OAuth2/Exceptions/TokenValidationException.cs new file mode 100644 index 0000000..734043b --- /dev/null +++ b/MTSC.OAuth2/Exceptions/TokenValidationException.cs @@ -0,0 +1,13 @@ +using System; + +namespace MTSC.OAuth2.Exceptions +{ + public sealed class TokenValidationException : Exception + { + private const string ErrorMessage = "Failed to validate access token. See inner exception for details"; + + public TokenValidationException(Exception innerException) : base(ErrorMessage, innerException) + { + } + } +} diff --git a/MTSC.OAuth2/Extensions/ServerExtensions.cs b/MTSC.OAuth2/Extensions/ServerExtensions.cs new file mode 100644 index 0000000..c207aaf --- /dev/null +++ b/MTSC.OAuth2/Extensions/ServerExtensions.cs @@ -0,0 +1,13 @@ +using MTSC.OAuth2.Builders; +using MTSC.ServerSide; + +namespace MTSC.OAuth2.Extensions +{ + public static class ServerExtensions + { + public static MicrosoftGraphAuthorizationBuilder WithMicrosoftGraphAuthorization(this Server server) + { + return new MicrosoftGraphAuthorizationBuilder(server); + } + } +} diff --git a/MTSC.OAuth2/Globals.cs b/MTSC.OAuth2/Globals.cs new file mode 100644 index 0000000..c571047 --- /dev/null +++ b/MTSC.OAuth2/Globals.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MTSC.OAuth2.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/MTSC.OAuth2/MTSC.OAuth2.csproj b/MTSC.OAuth2/MTSC.OAuth2.csproj new file mode 100644 index 0000000..21d1b61 --- /dev/null +++ b/MTSC.OAuth2/MTSC.OAuth2.csproj @@ -0,0 +1,32 @@ + + + + Library + netstandard2.0 + LICENSE + 1.0.0 + latest + Alexandru-Victor Macocian + MTSC.OAuth2 + https://github.com/AlexMacocian/MTSC + https://github.com/AlexMacocian/MTSC/blob/master/docs/MTSC_Logo.png + https://github.com/AlexMacocian/MTSC + true + + + + + True + + + + + + + + + + + + + diff --git a/MTSC.OAuth2/Models/AccessTokenResponse.cs b/MTSC.OAuth2/Models/AccessTokenResponse.cs new file mode 100644 index 0000000..8cc2a00 --- /dev/null +++ b/MTSC.OAuth2/Models/AccessTokenResponse.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace MTSC.OAuth2.Models +{ + public sealed class AccessTokenResponse + { + [JsonProperty("access_token")] + public string AccessToken { get; set; } + + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + } +} diff --git a/MTSC.OAuth2/Models/AccessTokenValidator.cs b/MTSC.OAuth2/Models/AccessTokenValidator.cs new file mode 100644 index 0000000..673fa73 --- /dev/null +++ b/MTSC.OAuth2/Models/AccessTokenValidator.cs @@ -0,0 +1,87 @@ +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using MTSC.OAuth2.Exceptions; +using System; +using System.Extensions; +using System.Linq; +using System.Threading.Tasks; + +namespace MTSC.OAuth2.Models +{ + internal sealed class AccessTokenValidator + { + private readonly OpenIdConfigurationCache openIdConfigurationCache; + private readonly string clientId; + private readonly AuthorizationHttpClientWrapper authorizationHttpClientWrapper; + + public AccessTokenValidator( + AuthorizationHttpClientWrapper authorizationHttpClientWrapper, + string clientId, + string openIdConfigurationUri) + { + this.authorizationHttpClientWrapper = authorizationHttpClientWrapper; + this.clientId = clientId; + this.openIdConfigurationCache = OpenIdConfigurationCache.GetForConfigurationUri(openIdConfigurationUri); + } + + public async Task> ValidateAccessToken(string accessToken) + { + var openIdConfiguration = await this.openIdConfigurationCache.GetOpenIdConfiguration(this.authorizationHttpClientWrapper); + + try + { + (var validationResult, var jsonWebToken) = await this.ValidateTokenInternal(accessToken, openIdConfiguration); + if (validationResult.Exception is Exception validationException) + { + return new TokenValidationException(validationException); + } + + return new TokenValidationResponse { JsonWebToken = jsonWebToken, IsValid = validationResult.IsValid }; + } + catch(Exception ex) + { + return new TokenValidationException(ex); + } + } + + private async Task<(TokenValidationResult, JsonWebToken)> ValidateTokenInternal(string accessToken, OpenIdConfiguration openIdConfiguration) + { + var jsonWebTokenHandler = new JsonWebTokenHandler(); + var token = new JsonWebToken(accessToken); + TokenValidationParameters validationParameters; + if (token.Audiences.Any(audience => audience == this.clientId)) + { + validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateLifetime = true, + ValidateAudience = true, + ValidAlgorithms = openIdConfiguration.SupportedAlgorithms, + ValidAudience = clientId, + ValidIssuer = openIdConfiguration.Issuer, + IssuerSigningKeys = openIdConfiguration.SigningKeys, + ValidateTokenReplay = true, + }; + } + else + { + validationParameters = new TokenValidationParameters + { + ValidateLifetime = true, + ValidateAudience = false, + ValidateActor = false, + ValidateIssuer = false, + ValidateIssuerSigningKey = false, + SignatureValidator = (token, parameters) => + { + var jwt = new JsonWebToken(accessToken); + return jwt; + } + }; + } + + var validation = await jsonWebTokenHandler.ValidateTokenAsync(accessToken, validationParameters); + return (validation, token); + } + } +} diff --git a/MTSC.OAuth2/Models/AuthorizationHttpClientWrapper.cs b/MTSC.OAuth2/Models/AuthorizationHttpClientWrapper.cs new file mode 100644 index 0000000..787047d --- /dev/null +++ b/MTSC.OAuth2/Models/AuthorizationHttpClientWrapper.cs @@ -0,0 +1,71 @@ +using System; +using System.Http; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +namespace MTSC.OAuth2.Models +{ + internal sealed class AuthorizationHttpClientWrapper + { + private readonly IHttpClient scopedHttpClient; + private readonly HttpClient httpClient; + + public AuthorizationHttpClientWrapper(IHttpClient scopedHttpClient) + { + this.scopedHttpClient = scopedHttpClient; + } + + public AuthorizationHttpClientWrapper(HttpClient httpClient) + { + this.httpClient = httpClient; + } + + public async Task PostAsync(string url, HttpContent httpContent) + { + if (this.scopedHttpClient is not null) + { + return await this.scopedHttpClient.PostAsync(url, httpContent); + } + + if (this.httpClient is not null) + { + return await this.httpClient.PostAsync(url, httpContent); + } + + throw new InvalidOperationException("No usable http client found"); + } + + public async Task GetAsync(string url) + { + if (this.scopedHttpClient is not null) + { + return await this.scopedHttpClient.GetAsync(url); + } + + if (this.httpClient is not null) + { + return await this.httpClient.GetAsync(url); + } + + throw new InvalidOperationException("No usable http client found"); + } + + public void SetAuthorizationHeader(string accessToken) + { + if (this.scopedHttpClient is not null) + { + this.scopedHttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return; + } + + if (this.httpClient is not null) + { + this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return; + } + + throw new InvalidOperationException("No usable http client found"); + } + } +} diff --git a/MTSC.OAuth2/Models/AuthorizationOptions.cs b/MTSC.OAuth2/Models/AuthorizationOptions.cs new file mode 100644 index 0000000..2d2c0c5 --- /dev/null +++ b/MTSC.OAuth2/Models/AuthorizationOptions.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography.X509Certificates; + +namespace MTSC.OAuth2.Models +{ + public sealed class AuthorizationOptions + { + public enum Authentication + { + ClientSecret, + ClientCertificate + } + + /// + /// Uri of the openid configuration for your OAuth provider. + /// + /// + /// Azure: "https://login.microsoftonline.com/common/.well-known/openid-configuration". + /// Tenant Azure: "https://login.microsoftonline.com/[TENANT]/.well-known/openid-configuration". + /// Google: "https://accounts.google.com/.well-known/openid-configuration". + /// Facebook: "https://www.facebook.com/.well-known/openid-configuration/" + /// + public string OpenIdConfigurationUri { get; set; } + /// + /// OAuth uri for the OAuth provider. + /// + /// + /// Azure: "https://login.microsoftonline.com/common/oauth2/v2.0". + /// Azure Tenant: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0". + /// Google: "https://accounts.google.com/o/oauth2/v2/auth". + /// Facebook: "https://graph.facebook.com/oauth" + /// + public string OAuthUri { get; set; } + /// + /// The endpoint for the access token. Most providers use /token but some providers use /access_token or other endpoints. + /// If your provider uses a different endpoint than /token, override this property with the correct endpoint. + /// + public string AuthTokenEndpoint { get; set; } = "token"; + /// + /// ClientID of your Application, as it is registered on your OAuth provider. + /// + public string ClientId { get; set; } + /// + /// Certificate that is whitelisted as your Application on your OAuth provider. + /// + public X509Certificate2 ClientCertificate { get; set; } + /// + /// Value provided by your OAuth provider to authenticate your service as your Application. + /// + public string ClientSecret { get; set; } + /// + /// URI to redirect the request once authorization succeeds. This URI needs to be whitelisted with your + /// OAuth provider. + /// + public string RedirectUri { get; set; } + /// + /// Scopes of your authorization request. Example "User.Read" to read the user information. + /// + public string Scopes { get; set; } + /// + /// Specify what to use for authentication. + /// When using , the authorization flow will use the to authenticate with the OAuth provider. + /// When using , the authorization flow will use the to authenticate with the OAuth provider. + /// + public Authentication AuthenticationMode { get; set; } + } +} diff --git a/MTSC.OAuth2/Models/OpenIdConfiguration.cs b/MTSC.OAuth2/Models/OpenIdConfiguration.cs new file mode 100644 index 0000000..ce01112 --- /dev/null +++ b/MTSC.OAuth2/Models/OpenIdConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace MTSC.OAuth2.Models +{ + internal sealed class OpenIdConfiguration + { + [JsonProperty("issuer")] + public string Issuer { get; set; } + [JsonProperty("id_token_signing_alg_values_supported")] + public List SupportedAlgorithms { get; set; } + [JsonProperty("claims_supported")] + public List SupportedClaims { get; set; } + [JsonProperty("jwks_uri")] + public string SigningKeysUri { get; set; } + public List SigningKeys { get; set; } + } +} diff --git a/MTSC.OAuth2/Models/OpenIdConfigurationCache.cs b/MTSC.OAuth2/Models/OpenIdConfigurationCache.cs new file mode 100644 index 0000000..537971b --- /dev/null +++ b/MTSC.OAuth2/Models/OpenIdConfigurationCache.cs @@ -0,0 +1,72 @@ +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace MTSC.OAuth2.Models +{ + internal sealed class OpenIdConfigurationCache + { + private static ConcurrentDictionary CacheDictionary = new(); + + private readonly string openIdConfigurationUri; + private readonly TimeSpan configurationExpirationTime = TimeSpan.FromMinutes(30); + + private DateTime lastConfigRetrieveTime = DateTime.MinValue; + private OpenIdConfiguration openIdConfiguration; + + private OpenIdConfigurationCache(string openIdConfigurationUri) + { + this.openIdConfigurationUri = openIdConfigurationUri; + } + + internal async Task GetOpenIdConfiguration(AuthorizationHttpClientWrapper httpClientWrapper) + { + if (this.openIdConfiguration is null || + DateTime.Now - this.lastConfigRetrieveTime > configurationExpirationTime) + { + await this.RetrieveOpenIdConfiguration(httpClientWrapper); + } + + return this.openIdConfiguration; + } + + private async Task RetrieveOpenIdConfiguration(AuthorizationHttpClientWrapper httpClientWrapper) + { + var response = await httpClientWrapper.GetAsync(openIdConfigurationUri); + if (response.IsSuccessStatusCode is false) + { + throw new InvalidOperationException($"Unable to retrieve openid configuration from {this.openIdConfigurationUri}. Response status code {response.StatusCode}"); + } + + var responseString = await response.Content.ReadAsStringAsync(); + this.openIdConfiguration = JsonConvert.DeserializeObject(responseString); + + var keysResponse = await httpClientWrapper.GetAsync(this.openIdConfiguration.SigningKeysUri); + if (keysResponse.IsSuccessStatusCode is false) + { + throw new InvalidOperationException($"Unable to retrieve openid signing keys from {this.openIdConfiguration.SigningKeysUri}. Response status code {response.StatusCode}"); + } + + var keysResponseString = await keysResponse.Content.ReadAsStringAsync(); + this.openIdConfiguration.SigningKeys = new List(JsonConvert.DeserializeObject(keysResponseString).SigningKeys.Select(signingKey => new JsonWebKey(JsonConvert.SerializeObject(signingKey)))); + this.lastConfigRetrieveTime = DateTime.Now; + } + + internal static OpenIdConfigurationCache GetForConfigurationUri(string configurationUri) + { + if (CacheDictionary.TryGetValue(configurationUri, out var openIdConfigurationCache)) + { + return openIdConfigurationCache; + } + + var newOpenIdConfigurationCache = new OpenIdConfigurationCache(configurationUri); + CacheDictionary.AddOrUpdate(configurationUri, newOpenIdConfigurationCache, (uri, oldValue) => newOpenIdConfigurationCache); + + return newOpenIdConfigurationCache; + } + } +} diff --git a/MTSC.OAuth2/Models/ServerDebugLogger.cs b/MTSC.OAuth2/Models/ServerDebugLogger.cs new file mode 100644 index 0000000..951e9b0 --- /dev/null +++ b/MTSC.OAuth2/Models/ServerDebugLogger.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; +using MTSC.ServerSide; +using System; + +namespace MTSC.OAuth2.Models +{ + internal sealed class ServerDebugLogger : ILogger + { + private readonly Server server; + + public ServerDebugLogger(Server server) + { + this.server = server; + } + + public IDisposable BeginScope(TState state) + { + throw new NotImplementedException(); + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + var message = formatter(state, exception); + this.server.LogDebug(message); + } + } +} diff --git a/MTSC.OAuth2/Models/SigningKeysResponse.cs b/MTSC.OAuth2/Models/SigningKeysResponse.cs new file mode 100644 index 0000000..5410454 --- /dev/null +++ b/MTSC.OAuth2/Models/SigningKeysResponse.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Collections.Generic; + +namespace MTSC.OAuth2.Models +{ + internal sealed class SigningKeysResponse + { + [JsonProperty("keys")] + public List SigningKeys { get; set; } + } +} diff --git a/MTSC.OAuth2/Models/TokenValidationResponse.cs b/MTSC.OAuth2/Models/TokenValidationResponse.cs new file mode 100644 index 0000000..ecadd4f --- /dev/null +++ b/MTSC.OAuth2/Models/TokenValidationResponse.cs @@ -0,0 +1,10 @@ +using Microsoft.IdentityModel.JsonWebTokens; + +namespace MTSC.OAuth2.Models +{ + public sealed class TokenValidationResponse + { + public bool IsValid { get; set; } + public JsonWebToken JsonWebToken { get; set; } + } +} diff --git a/MTSC.OAuth2/README.md b/MTSC.OAuth2/README.md new file mode 100644 index 0000000..8f79617 --- /dev/null +++ b/MTSC.OAuth2/README.md @@ -0,0 +1,14 @@ +# OAuth2 Web Server App Implementation + +## Setup +* Decorate your `HttpRouteBase` with `[Authorize]` +* Setup server wtih an `IAuthorizationProvider`. Alternatively use `server.WithMicrosoftGraphAuthorization()` extension to use the in-built Microsoft Graph authorization provider. +* [Optionally] implement `IHttpClient` resolver to have a scoped `HttpClient` for the OAuth flow. If no `IHttpClient` is present, the flow will use a `new HttpClient()` instance. +* [Optionally] implement `ILogger` resolver to have a scoped `ILogger` for the OAuth flow. If no `ILogger` is present, the flow will use `Server.LogDebug()`. + +## Functionality +* On the first request arriving at an endpoint decorated with `[Authorize]`, the route filter with check for the presence of `AccessToken` cookie. If it is not present, the filter will redirect the client to the OAuth server to peform the authorization. +* The OAuth server redirects the client, on a successful authorization, to the `RedirectUri`. The `RedirectUri` endpoint must also be decorated with `[Authorize]`. +* The redirect endpoint picks up the authorization code from the OAuth server and performs a request to the OAuth server to retrieve the `AccessToken`. The filter will use the `ClientSecret` as identifier of your Web Server App to the OAuth server. +* Once the OAuth server returns the `AccessToken`, the filter will set a `RouteContext.Resources` resource with the key `AccessToken` and value of the code provided by the OAuth server. +* On response from the Web Server App, the filter will also set a response cookie with the `AccessToken`. \ No newline at end of file diff --git a/MTSC.sln b/MTSC.sln index 6367363..d5a6dca 100644 --- a/MTSC.sln +++ b/MTSC.sln @@ -18,6 +18,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipelines", "Pipelines", "{ .github\workflows\codeql-analysis.yml = .github\workflows\codeql-analysis.yml EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MTSC.OAuth2", "MTSC.OAuth2\MTSC.OAuth2.csproj", "{E1B2524A-D117-408E-A8B1-FDDE4B46AE24}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MTSC.OAuth2.Tests", "MTSC.OAuth2.Tests\MTSC.OAuth2.Tests.csproj", "{8BE61757-1CC0-4571-8F6E-3BE0888BD17D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -58,6 +62,22 @@ Global {55CC0666-5599-4C3C-ADB5-E48F06361C52}.Release|Any CPU.Build.0 = Release|Any CPU {55CC0666-5599-4C3C-ADB5-E48F06361C52}.Release|x64.ActiveCfg = Release|Any CPU {55CC0666-5599-4C3C-ADB5-E48F06361C52}.Release|x64.Build.0 = Release|Any CPU + {E1B2524A-D117-408E-A8B1-FDDE4B46AE24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1B2524A-D117-408E-A8B1-FDDE4B46AE24}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1B2524A-D117-408E-A8B1-FDDE4B46AE24}.Debug|x64.ActiveCfg = Debug|Any CPU + {E1B2524A-D117-408E-A8B1-FDDE4B46AE24}.Debug|x64.Build.0 = Debug|Any CPU + {E1B2524A-D117-408E-A8B1-FDDE4B46AE24}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1B2524A-D117-408E-A8B1-FDDE4B46AE24}.Release|Any CPU.Build.0 = Release|Any CPU + {E1B2524A-D117-408E-A8B1-FDDE4B46AE24}.Release|x64.ActiveCfg = Release|Any CPU + {E1B2524A-D117-408E-A8B1-FDDE4B46AE24}.Release|x64.Build.0 = Release|Any CPU + {8BE61757-1CC0-4571-8F6E-3BE0888BD17D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BE61757-1CC0-4571-8F6E-3BE0888BD17D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BE61757-1CC0-4571-8F6E-3BE0888BD17D}.Debug|x64.ActiveCfg = Debug|Any CPU + {8BE61757-1CC0-4571-8F6E-3BE0888BD17D}.Debug|x64.Build.0 = Debug|Any CPU + {8BE61757-1CC0-4571-8F6E-3BE0888BD17D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BE61757-1CC0-4571-8F6E-3BE0888BD17D}.Release|Any CPU.Build.0 = Release|Any CPU + {8BE61757-1CC0-4571-8F6E-3BE0888BD17D}.Release|x64.ActiveCfg = Release|Any CPU + {8BE61757-1CC0-4571-8F6E-3BE0888BD17D}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE