Setup OAuth2 support (#20)

* Setup OAuth2 support

* Setup tests project

* Setup OAuth2 unit tests in CI pipeline

* Setup authorizeattribute tests

* Test

* Setup tests for AuthorizeAttribute

* Setup OAuth2 support
Unit Tests coverage

* Add OAuth2 project to release
This commit is contained in:
2022-09-02 16:13:08 +02:00
committed by GitHub
parent b759869ed6
commit 0fe9f9957c
30 changed files with 2058 additions and 2 deletions
+5 -1
View File
@@ -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
+6 -1
View File
@@ -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:
+29
View File
@@ -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));
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="Moq" Version="4.18.1" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
<PackageReference Include="coverlet.collector" Version="3.1.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.20.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MTSC.OAuth2\MTSC.OAuth2.csproj" />
</ItemGroup>
</Project>
@@ -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<HttpRequestMessage, Task<HttpResponseMessage>> SendingAsync { get;set; }
public Func<HttpRequestMessage, HttpResponseMessage> Sending { get; set; }
public override async Task<HttpResponseMessage> 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();
}
}
}
@@ -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<T> : IHttpClient<T>
{
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<HttpClientEventMessage> EventEmitted;
public void CancelPendingRequests()
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> DeleteAsync(string requestUri)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> DeleteAsync(string requestUri, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> DeleteAsync(Uri requestUri)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> DeleteAsync(Uri requestUri, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> GetAsync(string requestUri)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> GetAsync(string requestUri, HttpCompletionOption completionOption)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> GetAsync(string requestUri, HttpCompletionOption completionOption, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> GetAsync(string requestUri, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> GetAsync(Uri requestUri)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> GetAsync(Uri requestUri, HttpCompletionOption completionOption)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> GetAsync(Uri requestUri, HttpCompletionOption completionOption, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> GetAsync(Uri requestUri, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<byte[]> GetByteArrayAsync(string requestUri)
{
throw new NotImplementedException();
}
public Task<byte[]> GetByteArrayAsync(Uri requestUri)
{
throw new NotImplementedException();
}
public Task<Stream> GetStreamAsync(string requestUri)
{
throw new NotImplementedException();
}
public Task<Stream> GetStreamAsync(Uri requestUri)
{
throw new NotImplementedException();
}
public Task<string> GetStringAsync(string requestUri)
{
throw new NotImplementedException();
}
public Task<string> GetStringAsync(Uri requestUri)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> PostAsync(string requestUri, HttpContent content)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> PostAsync(string requestUri, HttpContent content, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> PostAsync(Uri requestUri, HttpContent content)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> PostAsync(Uri requestUri, HttpContent content, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> PutAsync(string requestUri, HttpContent content)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> PutAsync(string requestUri, HttpContent content, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> PutAsync(Uri requestUri, HttpContent content)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> PutAsync(Uri requestUri, HttpContent content, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}
}
@@ -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<ILogger<AuthorizeAttribute>> loggerMock = new();
private readonly Mock<IAuthorizationProvider> 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<AuthorizeAttribute>();
var action = () => container.GetService<AuthorizeAttribute>();
action.Should().Throw<DependencyInjectionException>();
}
[TestMethod]
public void Constructors_PreferresConstructorWithILogger()
{
var calledExpectedConstructor = false;
var container = new ServiceManager();
container.RegisterSingleton<AuthorizeAttribute>();
container.RegisterSingleton(sp => this.authorizationProviderMock.Object);
container.RegisterSingleton(sp =>
{
calledExpectedConstructor = true;
return this.loggerMock.Object;
});
container.RegisterSingleton(sp => new Server());
_ = container.GetService<AuthorizeAttribute>();
calledExpectedConstructor.Should().BeTrue();
}
[TestMethod]
public void Constructor1_AuthorizationProviderIsNull_ThrowsArgumentNullException()
{
var action = () => new AuthorizeAttribute(null, this.loggerMock.Object);
action.Should().Throw<ArgumentNullException>();
}
[TestMethod]
public void Constructor1_ILoggerIsNull_ThrowsArgumentNullException()
{
var action = () => new AuthorizeAttribute(this.authorizationProviderMock.Object, null as ILogger<AuthorizeAttribute>);
action.Should().Throw<ArgumentNullException>();
}
[TestMethod]
public void Constructor2_AuthorizationProviderIsNull_ThrowsArgumentNullException()
{
var action = () => new AuthorizeAttribute(null, new Server());
action.Should().Throw<ArgumentNullException>();
}
[TestMethod]
public void Constructor2_ServerIsNull_ThrowsArgumentNullException()
{
var action = () => new AuthorizeAttribute(this.authorizationProviderMock.Object, null as Server);
action.Should().Throw<ArgumentNullException>();
}
[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<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
}
[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<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.ReturnsAsync(OAuthUri);
var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.ReturnsAsync(OAuthUri);
var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.ReturnsAsync(OAuthUri);
httpRequest.Cookies.Add(new Cookie(StateKey, StateValue));
var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.ReturnsAsync(OAuthUri);
httpRequest.Cookies.Add(new Cookie(StateKey, StateValue));
var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.ReturnsAsync(OAuthUri);
httpRequest.Cookies.Add(new Cookie(StateKey, StateValue));
httpRequest.RequestQuery = "someKey=someValue";
var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.ReturnsAsync(OAuthUri);
httpRequest.Cookies.Add(new Cookie(StateKey, StateValue));
httpRequest.RequestQuery = $"{StateKey}=someValue";
var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.ReturnsAsync(OAuthUri);
httpRequest.Cookies.Add(new Cookie(StateKey, StateValue));
httpRequest.RequestQuery = $"{StateKey}={StateValue}";
var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.ReturnsAsync(OAuthUri);
this.authorizationProviderMock.Setup(u => u.RetrieveAccessToken(AuthorizationCodeValue))
.ReturnsAsync(Optional.None<JsonWebToken>());
httpRequest.Cookies.Add(new Cookie(StateKey, StateValue));
httpRequest.RequestQuery = $"{StateKey}={StateValue}&{AuthorizationCodeKey}={AuthorizationCodeValue}";
_ = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.ReturnsAsync(OAuthUri);
this.authorizationProviderMock.Setup(u => u.RetrieveAccessToken(AuthorizationCodeValue))
.ReturnsAsync(Optional.None<JsonWebToken>());
httpRequest.Cookies.Add(new Cookie(StateKey, StateValue));
httpRequest.RequestQuery = $"{StateKey}={StateValue}&{AuthorizationCodeKey}={AuthorizationCodeValue}";
var response = (await this.authorizeAttribute.HandleRequestAsync(routeContext)).Cast<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.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<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.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<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
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<string>()))
.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<RouteEnablerAsyncResponse.RouteEnablerAsyncResponseError>();
routeContext.Resources.TryGetValue(AccessTokenKey, out var accessTokenString).Should().BeTrue();
accessTokenString.Should().NotBeNull();
routeContext.Resources.TryGetValue(AccessTokenObject, out var accessTokenObject).Should().BeTrue();
accessTokenObject.Should().BeOfType<JsonWebToken>();
}
}
}
@@ -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<ClientCertificateAuthorizationProvider> 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<ClientCertificateAuthorizationProvider>();
container.RegisterSingleton<IHttpClient<ClientCertificateAuthorizationProvider>>(sp => this.httpClientMock);
container.RegisterSingleton(sp => this.authorizationOptions);
var provider = container.GetService<ClientCertificateAuthorizationProvider>();
provider.Should().NotBeNull();
}
[TestMethod]
public void Constructor_WithHttpClient_AndOptions_Builds()
{
var container = new ServiceManager();
container.RegisterSingleton<ClientCertificateAuthorizationProvider>();
container.RegisterSingleton<HttpClient>(sp => this.httpClientMock2);
container.RegisterSingleton(sp => this.authorizationOptions);
var provider = container.GetService<ClientCertificateAuthorizationProvider>();
provider.Should().NotBeNull();
}
[TestMethod]
public void Constructor_WithOptions_Builds()
{
var container = new ServiceManager();
container.RegisterSingleton<ClientCertificateAuthorizationProvider>();
container.RegisterSingleton(sp => this.authorizationOptions);
var provider = container.GetService<ClientCertificateAuthorizationProvider>();
provider.Should().NotBeNull();
}
[TestMethod]
public async Task RetrieveAccessToken_SetsClientCertificate()
{
this.httpClientMock2.SendingAsync = async (request) =>
{
request.Content.Should().BeOfType<FormUrlEncodedContent>();
var contentString = await request.Content.As<FormUrlEncodedContent>().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);
}
}
@@ -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<ClientSecretAuthorizationProvider> 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<ClientSecretAuthorizationProvider>();
container.RegisterSingleton<IHttpClient<ClientSecretAuthorizationProvider>>(sp => this.httpClientMock);
container.RegisterSingleton(sp => this.authorizationOptions);
var provider = container.GetService<ClientSecretAuthorizationProvider>();
provider.Should().NotBeNull();
}
[TestMethod]
public void Constructor_WithHttpClient_AndOptions_Builds()
{
var container = new ServiceManager();
container.RegisterSingleton<ClientSecretAuthorizationProvider>();
container.RegisterSingleton<HttpClient>(sp => this.httpClientMock2);
container.RegisterSingleton(sp => this.authorizationOptions);
var provider = container.GetService<ClientSecretAuthorizationProvider>();
provider.Should().NotBeNull();
}
[TestMethod]
public void Constructor_WithOptions_Builds()
{
var container = new ServiceManager();
container.RegisterSingleton<ClientSecretAuthorizationProvider>();
container.RegisterSingleton(sp => this.authorizationOptions);
var provider = container.GetService<ClientSecretAuthorizationProvider>();
provider.Should().NotBeNull();
}
[TestMethod]
public async Task RetrieveAccessToken_SetsClientSecret()
{
this.httpClientMock2.SendingAsync = async (request) =>
{
request.Content.Should().BeOfType<FormUrlEncodedContent>();
var contentString = await request.Content.As<FormUrlEncodedContent>().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);
}
}
@@ -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<AuthorizeAttribute> logger;
private readonly IAuthorizationProvider authorizationProvider;
[DoNotInject]
///<summary>
/// Public parameterless constructor to be used for decorating endpoints. Do not use to instantiate new <see cref="AuthorizeAttribute"/>.
/// Use instead any of the other constructors.
///</summary>
public AuthorizeAttribute()
{
}
[PreferredConstructor(Priority = 0)]
public AuthorizeAttribute(
IAuthorizationProvider authorizationProvider,
ILogger<AuthorizeAttribute> 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<AuthorizeAttribute>(server.ThrowIfNull(nameof(server)));
}
public override async Task<RouteEnablerAsyncResponse> 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<RouteEnablerAsyncResponse> 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<RouteEnablerAsyncResponse> RedirectToUri(JsonWebToken accessTokenObject)
{
this.logger.LogDebug($"Redirecting to redirect uri");
return RouteEnablerAsyncResponse.Error(await this.RedirectToUri307(accessTokenObject));
}
private async Task<RouteEnablerAsyncResponse> ExpireAccessTokenAndRedirect(string accessToken)
{
this.logger.LogDebug($"Expiring access token and redirecting");
return RouteEnablerAsyncResponse.Error(await this.ExpireAccessAndRedirect307(accessToken));
}
private async Task<HttpResponse> 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<HttpResponse> 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<HttpResponse> 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<string, string> 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"
};
}
}
@@ -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<ClientCertificateAuthorizationProvider>
{
private readonly AuthorizationOptions authorizationOptions;
[PreferredConstructor(Priority = 0)]
public ClientCertificateAuthorizationProvider(
IHttpClient<ClientCertificateAuthorizationProvider> 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<KeyValuePair<string, string>> 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<string, string>
{
{ "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" },
{ "client_assertion", signedClientAssertion }
};
}
private Dictionary<string, object> GetClaims()
{
return new Dictionary<string, object>()
{
{ "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 }
};
}
}
}
@@ -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<ClientSecretAuthorizationProvider>
{
[PreferredConstructor(Priority = 0)]
public ClientSecretAuthorizationProvider(
AuthorizationOptions options,
IHttpClient<ClientSecretAuthorizationProvider> 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<KeyValuePair<string, string>> GetAdditionalFormFields()
{
return new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("client_secret", this.Options.ClientSecret)
};
}
}
}
@@ -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<T> : 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<T> accessTokenValidator;
protected AuthorizationHttpClientWrapper<T> AuthorizationClientWrapper { get; set; }
protected AuthorizationOptions Options { get; set; }
public FormAuthorizationProvider(
IHttpClient<T> httpClient,
AuthorizationOptions options)
{
this.AuthorizationClientWrapper = new AuthorizationHttpClientWrapper<T>(httpClient);
this.Options = options ?? throw new ArgumentNullException(nameof(options));
this.accessTokenValidator = new AccessTokenValidator<T>(this.AuthorizationClientWrapper, this.Options.ClientId, this.Options.OpenIdConfigurationUri);
}
public FormAuthorizationProvider(
HttpClient httpClient,
AuthorizationOptions options)
{
this.AuthorizationClientWrapper = new AuthorizationHttpClientWrapper<T>(httpClient);
this.Options = options ?? throw new ArgumentNullException(nameof(options));
this.accessTokenValidator = new AccessTokenValidator<T>(this.AuthorizationClientWrapper, this.Options.ClientId, this.Options.OpenIdConfigurationUri);
}
public async Task<TokenValidationResponse> VerifyAccessToken(string accessToken)
{
var validationResult = await this.accessTokenValidator.ValidateAccessToken(accessToken);
return validationResult.Switch<TokenValidationResponse>(
onSuccess: result => result,
onFailure: exception =>
{
return new TokenValidationResponse { IsValid = false };
});
}
public Task<string> 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<Optional<JsonWebToken>> RetrieveAccessToken(
string authorizationCode)
{
using var formContent = new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "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<JsonWebToken>();
}
var accessToken = JsonConvert.DeserializeObject<AccessTokenResponse>(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<string> GetRedirectUri()
{
return Task.FromResult(this.Options.RedirectUri);
}
protected virtual IEnumerable<KeyValuePair<string, string>> GetAdditionalFormFields() { return Array.Empty<KeyValuePair<string, string>>(); }
}
}
@@ -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<Optional<JsonWebToken>> RetrieveAccessToken(string authorizationCode);
Task<TokenValidationResponse> VerifyAccessToken(string accessToken);
Task<string> GetOAuthUri(string state);
Task<string> GetRedirectUri();
}
}
@@ -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<IAuthorizationProvider, ClientSecretAuthorizationProvider>();
}
else if (this.ClientCertificate is not null)
{
options.ClientCertificate = this.ClientCertificate;
options.AuthenticationMode = Authentication.ClientCertificate;
this.chainedServer.ServiceManager.RegisterScoped<IAuthorizationProvider, ClientCertificateAuthorizationProvider>();
}
else if (this.ClientCertificateThumbprint.ThrowIfNull(nameof(this.ClientCertificateThumbprint)) is not null)
{
options.ClientCertificate = GetClientCertificate(this.ClientCertificateThumbprint);
options.AuthenticationMode = Authentication.ClientCertificate;
this.chainedServer.ServiceManager.RegisterScoped<IAuthorizationProvider, ClientCertificateAuthorizationProvider>();
}
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;
}
}
}
@@ -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)
{
}
}
}
@@ -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);
}
}
}
+4
View File
@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MTSC.OAuth2.Tests")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
+32
View File
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFrameworks>netstandard2.0</TargetFrameworks>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<Version>1.0.0</Version>
<LangVersion>latest</LangVersion>
<Authors>Alexandru-Victor Macocian</Authors>
<Product>MTSC.OAuth2</Product>
<PackageProjectUrl>https://github.com/AlexMacocian/MTSC</PackageProjectUrl>
<PackageIconUrl>https://github.com/AlexMacocian/MTSC/blob/master/docs/MTSC_Logo.png</PackageIconUrl>
<RepositoryUrl>https://github.com/AlexMacocian/MTSC</RepositoryUrl>
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
</PropertyGroup>
<ItemGroup>
<None Include="..\LICENSE" Link="LICENSE">
<Pack>True</Pack>
<PackagePath></PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" Version="6.0.0" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="6.17.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="6.17.0" />
<PackageReference Include="MTSC" Version="5.4.3" />
<PackageReference Include="SystemExtensions.NetStandard" Version="1.4.1" />
</ItemGroup>
</Project>
+13
View File
@@ -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; }
}
}
@@ -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<T>
{
private readonly OpenIdConfigurationCache openIdConfigurationCache;
private readonly string clientId;
private readonly AuthorizationHttpClientWrapper<T> authorizationHttpClientWrapper;
public AccessTokenValidator(
AuthorizationHttpClientWrapper<T> authorizationHttpClientWrapper,
string clientId,
string openIdConfigurationUri)
{
this.authorizationHttpClientWrapper = authorizationHttpClientWrapper;
this.clientId = clientId;
this.openIdConfigurationCache = OpenIdConfigurationCache.GetForConfigurationUri(openIdConfigurationUri);
}
public async Task<Result<TokenValidationResponse, TokenValidationException>> 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);
}
}
}
@@ -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<T>
{
private readonly IHttpClient<T> scopedHttpClient;
private readonly HttpClient httpClient;
public AuthorizationHttpClientWrapper(IHttpClient<T> scopedHttpClient)
{
this.scopedHttpClient = scopedHttpClient;
}
public AuthorizationHttpClientWrapper(HttpClient httpClient)
{
this.httpClient = httpClient;
}
public async Task<HttpResponseMessage> 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<HttpResponseMessage> 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");
}
}
}
@@ -0,0 +1,66 @@
using System.Security.Cryptography.X509Certificates;
namespace MTSC.OAuth2.Models
{
public sealed class AuthorizationOptions
{
public enum Authentication
{
ClientSecret,
ClientCertificate
}
/// <summary>
/// Uri of the openid configuration for your OAuth provider.
/// </summary>
/// <remarks>
/// 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/"
/// </remarks>
public string OpenIdConfigurationUri { get; set; }
/// <summary>
/// OAuth uri for the OAuth provider.
/// </summary>
/// <remarks>
/// 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"
/// </remarks>
public string OAuthUri { get; set; }
/// <summary>
/// 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.
/// </summary>
public string AuthTokenEndpoint { get; set; } = "token";
/// <summary>
/// ClientID of your Application, as it is registered on your OAuth provider.
/// </summary>
public string ClientId { get; set; }
/// <summary>
/// Certificate that is whitelisted as your Application on your OAuth provider.
/// </summary>
public X509Certificate2 ClientCertificate { get; set; }
/// <summary>
/// Value provided by your OAuth provider to authenticate your service as your Application.
/// </summary>
public string ClientSecret { get; set; }
/// <summary>
/// URI to redirect the request once authorization succeeds. This URI needs to be whitelisted with your
/// OAuth provider.
/// </summary>
public string RedirectUri { get; set; }
/// <summary>
/// Scopes of your authorization request. Example "User.Read" to read the user information.
/// </summary>
public string Scopes { get; set; }
/// <summary>
/// Specify what to use for authentication.
/// When using <see cref="Authentication.ClientSecret"/>, the authorization flow will use the <see cref="ClientSecret"/> to authenticate with the OAuth provider.
/// When using <see cref="Authentication.ClientCertificate"/>, the authorization flow will use the <see cref="ClientCertificateThumbprint"/> to authenticate with the OAuth provider.
/// </summary>
public Authentication AuthenticationMode { get; set; }
}
}
+19
View File
@@ -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<string> SupportedAlgorithms { get; set; }
[JsonProperty("claims_supported")]
public List<string> SupportedClaims { get; set; }
[JsonProperty("jwks_uri")]
public string SigningKeysUri { get; set; }
public List<JsonWebKey> SigningKeys { get; set; }
}
}
@@ -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<string, OpenIdConfigurationCache> 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<OpenIdConfiguration> GetOpenIdConfiguration<T>(AuthorizationHttpClientWrapper<T> httpClientWrapper)
{
if (this.openIdConfiguration is null ||
DateTime.Now - this.lastConfigRetrieveTime > configurationExpirationTime)
{
await this.RetrieveOpenIdConfiguration(httpClientWrapper);
}
return this.openIdConfiguration;
}
private async Task RetrieveOpenIdConfiguration<T>(AuthorizationHttpClientWrapper<T> 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<OpenIdConfiguration>(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<JsonWebKey>(JsonConvert.DeserializeObject<SigningKeysResponse>(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;
}
}
}
+29
View File
@@ -0,0 +1,29 @@
using Microsoft.Extensions.Logging;
using MTSC.ServerSide;
using System;
namespace MTSC.OAuth2.Models
{
internal sealed class ServerDebugLogger<T> : ILogger<T>
{
private readonly Server server;
public ServerDebugLogger(Server server)
{
this.server = server;
}
public IDisposable BeginScope<TState>(TState state)
{
throw new NotImplementedException();
}
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
{
var message = formatter(state, exception);
this.server.LogDebug(message);
}
}
}
+12
View File
@@ -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<JObject> SigningKeys { get; set; }
}
}
@@ -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; }
}
}
+14
View File
@@ -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<T>` resolver to have a scoped `HttpClient` for the OAuth flow. If no `IHttpClient<T>` is present, the flow will use a `new HttpClient()` instance.
* [Optionally] implement `ILogger<T>` resolver to have a scoped `ILogger` for the OAuth flow. If no `ILogger<T>` 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`.
+20
View File
@@ -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