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