From d92bbff064c792ba3d614418dd65a525ef45b06a Mon Sep 17 00:00:00 2001 From: Maxwell Weru Date: Tue, 30 May 2023 15:05:10 +0300 Subject: [PATCH] Open source Tingle.AspNetCore.Authentication --- README.md | 1 + Tingle.Extensions.sln | 35 +- .../AuthenticationSample.csproj | 7 + samples/AuthenticationSample/Program.cs | 12 + .../Properties/launchSettings.json | 27 + .../AddressClaim.cs | 52 + .../Constants.cs | 15 + .../CustomJsonSerializerContext.cs | 6 + ...enticationBuilderExtensions.PassThrough.cs | 50 + ...thenticationBuilderExtensions.SharedKey.cs | 53 + .../Extensions/ClaimsPrincipalExtensions.cs | 200 ++++ .../Extensions/ILoggerExtensions.cs | 15 + .../PassThrough/PassThroughContext.cs | 30 + .../PassThrough/PassThroughDefaults.cs | 12 + .../PassThrough/PassThroughEvents.cs | 9 + .../PassThrough/PassThroughHandler.cs | 58 + .../PassThrough/PassThroughOptions.cs | 20 + .../PassThroughPostConfigureOptions.cs | 17 + .../README.md | 83 ++ .../SharedKey/AuthenticationFailedContext.cs | 26 + .../SharedKey/ForbiddenContext.cs | 19 + .../SharedKey/MessageReceivedContext.cs | 26 + .../SharedKey/SharedKeyChallengeContext.cs | 58 + .../SharedKey/SharedKeyDefaults.cs | 22 + .../SharedKey/SharedKeyEvents.cs | 44 + .../SharedKey/SharedKeyHandler.cs | 297 +++++ .../SharedKey/SharedKeyOptions.cs | 57 + .../SharedKeyPostConfigureOptions.cs | 37 + .../SharedKey/TokenValidatedContext.cs | 27 + .../SharedKeyInvalidDateException.cs | 32 + .../SharedKeyInvalidSignatureException.cs | 20 + .../SharedKeyInvalidSigningKeysException.cs | 33 + .../Exceptions/SharedKeyNoDateException.cs | 39 + .../Exceptions/SharedKeyNoKeysException.cs | 20 + .../SharedKeyTimeWindowExpiredException.cs | 41 + .../Exceptions/SharedKeyTokenException.cs | 20 + .../Validation/ISharedKeyTokenValidator.cs | 22 + .../Validation/SharedKeyTokenHandler.cs | 140 +++ .../SharedKeyTokenValidationParameters.cs | 47 + .../Validation/SharedKeyValidatedToken.cs | 24 + .../Tingle.AspNetCore.Authentication.csproj | 11 + .../ClaimsPrincipalExtensionsTests.cs | 284 +++++ .../PassThroughTests.cs | 27 + .../SharedAuthenticationTests.cs | 505 ++++++++ .../SharedKeyTests.cs | 1015 +++++++++++++++++ .../TestAuthHandler.cs | 114 ++ .../TestClock.cs | 18 + .../TestExtensions.cs | 79 ++ ...gle.AspNetCore.Authentication.Tests.csproj | 11 + .../Transaction.cs | 57 + 50 files changed, 3867 insertions(+), 7 deletions(-) create mode 100644 samples/AuthenticationSample/AuthenticationSample.csproj create mode 100644 samples/AuthenticationSample/Program.cs create mode 100644 samples/AuthenticationSample/Properties/launchSettings.json create mode 100644 src/Tingle.AspNetCore.Authentication/AddressClaim.cs create mode 100644 src/Tingle.AspNetCore.Authentication/Constants.cs create mode 100644 src/Tingle.AspNetCore.Authentication/CustomJsonSerializerContext.cs create mode 100644 src/Tingle.AspNetCore.Authentication/Extensions/AuthenticationBuilderExtensions.PassThrough.cs create mode 100644 src/Tingle.AspNetCore.Authentication/Extensions/AuthenticationBuilderExtensions.SharedKey.cs create mode 100644 src/Tingle.AspNetCore.Authentication/Extensions/ClaimsPrincipalExtensions.cs create mode 100644 src/Tingle.AspNetCore.Authentication/Extensions/ILoggerExtensions.cs create mode 100644 src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughContext.cs create mode 100644 src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughDefaults.cs create mode 100644 src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughEvents.cs create mode 100644 src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughHandler.cs create mode 100644 src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughOptions.cs create mode 100644 src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughPostConfigureOptions.cs create mode 100644 src/Tingle.AspNetCore.Authentication/README.md create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/AuthenticationFailedContext.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/ForbiddenContext.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/MessageReceivedContext.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyChallengeContext.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyDefaults.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyEvents.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyHandler.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyOptions.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyPostConfigureOptions.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/TokenValidatedContext.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidDateException.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidSignatureException.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidSigningKeysException.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyNoDateException.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyNoKeysException.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyTimeWindowExpiredException.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyTokenException.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/Validation/ISharedKeyTokenValidator.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyTokenHandler.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyTokenValidationParameters.cs create mode 100644 src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyValidatedToken.cs create mode 100644 src/Tingle.AspNetCore.Authentication/Tingle.AspNetCore.Authentication.csproj create mode 100644 tests/Tingle.AspNetCore.Authentication.Tests/ClaimsPrincipalExtensionsTests.cs create mode 100644 tests/Tingle.AspNetCore.Authentication.Tests/PassThroughTests.cs create mode 100644 tests/Tingle.AspNetCore.Authentication.Tests/SharedAuthenticationTests.cs create mode 100644 tests/Tingle.AspNetCore.Authentication.Tests/SharedKeyTests.cs create mode 100644 tests/Tingle.AspNetCore.Authentication.Tests/TestAuthHandler.cs create mode 100644 tests/Tingle.AspNetCore.Authentication.Tests/TestClock.cs create mode 100644 tests/Tingle.AspNetCore.Authentication.Tests/TestExtensions.cs create mode 100644 tests/Tingle.AspNetCore.Authentication.Tests/Tingle.AspNetCore.Authentication.Tests.csproj create mode 100644 tests/Tingle.AspNetCore.Authentication.Tests/Transaction.cs diff --git a/README.md b/README.md index 48ba1e9..173c8a2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This repository contains projects/libraries for adding useful functionality to d |Package|Version|Description| |--|--|--| +|`Tingle.AspNetCore.Authentication`|[![NuGet](https://img.shields.io/nuget/v/Tingle.AspNetCore.Authentication.svg)](https://www.nuget.org/packages/Tingle.AspNetCore.Authentication/)|Convenience authentication functionality such as pass through and pre-shared key authentication mechanisms. See [docs](./src/Tingle.AspNetCore.Authentication/README.md) and [sample](./samples/AuthenticationSample)| |`Tingle.AspNetCore.Authorization`|[![NuGet](https://img.shields.io/nuget/v/Tingle.AspNetCore.Authorization.svg)](https://www.nuget.org/packages/Tingle.AspNetCore.Authorization/)|Additional authorization functionality such as handlers and requirements. See [docs](./src/Tingle.AspNetCore.Authorization/README.md) and [sample](./samples/AuthorizationSample)| |`Tingle.AspNetCore.DataProtection.MongoDB`|[![NuGet](https://img.shields.io/nuget/v/Tingle.AspNetCore.DataProtection.MongoDB.svg)](https://www.nuget.org/packages/Tingle.AspNetCore.DataProtection.MongoDB/)|Data Protection store in [MongoDB](https://mongodb.com) for ASP.NET Core. See [docs](./src/Tingle.AspNetCore.DataProtection.MongoDB/README.md) and [sample](./samples/DataProtectionMongoDBSample).| |`Tingle.AspNetCore.JsonPatch.NewtonsoftJson`|[![NuGet](https://img.shields.io/nuget/v/Tingle.AspNetCore.JsonPatch.NewtonsoftJson.svg)](https://www.nuget.org/packages/Tingle.AspNetCore.JsonPatch.NewtonsoftJson/)|Helpers for validation when working with JsonPatch in ASP.NET Core. See [docs](./src/Tingle.AspNetCore.JsonPatch.NewtonsoftJson/README.md) and [blog](https://medium.com/swlh/immutable-properties-with-json-patch-in-aspnet-core-25185f493ea8).| diff --git a/Tingle.Extensions.sln b/Tingle.Extensions.sln index ed88cbf..1e6176a 100644 --- a/Tingle.Extensions.sln +++ b/Tingle.Extensions.sln @@ -8,6 +8,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9546186D-D4E src\Directory.Build.props = src\Directory.Build.props EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Authentication", "src\Tingle.AspNetCore.Authentication\Tingle.AspNetCore.Authentication.csproj", "{98F3A2B7-5774-4E38-8FA0-FA13B6134454}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Authorization", "src\Tingle.AspNetCore.Authorization\Tingle.AspNetCore.Authorization.csproj", "{22690754-DCFB-4CD2-968D-239C1952B52C}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.DataProtection.MongoDB", "src\Tingle.AspNetCore.DataProtection.MongoDB\Tingle.AspNetCore.DataProtection.MongoDB.csproj", "{6F93ED1D-6475-46F2-A7DB-B2A5F9DB5A83}" @@ -33,6 +35,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{815F0941 tests\Directory.Build.props = tests\Directory.Build.props EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Authentication.Tests", "tests\Tingle.AspNetCore.Authentication.Tests\Tingle.AspNetCore.Authentication.Tests.csproj", "{A324CC70-36DD-4B38-9EC8-9069F6130FAC}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Authorization.Tests", "tests\Tingle.AspNetCore.Authorization.Tests\Tingle.AspNetCore.Authorization.Tests.csproj", "{E67CB6B9-6F42-4E63-9603-810B5B9FBF57}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.DataProtection.MongoDB.Tests", "tests\Tingle.AspNetCore.DataProtection.MongoDB.Tests\Tingle.AspNetCore.DataProtection.MongoDB.Tests.csproj", "{E28F4E8D-148B-4583-A27D-E1DA2CC08167}" @@ -58,10 +62,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{CE71 samples\Directory.Build.props = samples\Directory.Build.props EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AuthorizationSample", "samples\AuthorizationSample\AuthorizationSample.csproj", "{B97964EC-658A-4205-AA5A-BB814B191C35}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCoreSessionState", "samples\AspNetCoreSessionState\AspNetCoreSessionState.csproj", "{CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AuthenticationSample", "samples\AuthenticationSample\AuthenticationSample.csproj", "{E04EC969-2539-46E9-B918-8C8B7BEB8828}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AuthorizationSample", "samples\AuthorizationSample\AuthorizationSample.csproj", "{B97964EC-658A-4205-AA5A-BB814B191C35}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DataProtectionMongoDBSample", "samples\DataProtectionMongoDBSample\DataProtectionMongoDBSample.csproj", "{ACED6271-2F75-4E3B-BB79-9D40B74D6A51}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpAuthenticationSample", "samples\HttpAuthenticationSample\HttpAuthenticationSample.csproj", "{37ED98F0-3129-49B8-BF60-3BDEBBB34822}" @@ -74,6 +80,10 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {98F3A2B7-5774-4E38-8FA0-FA13B6134454}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98F3A2B7-5774-4E38-8FA0-FA13B6134454}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98F3A2B7-5774-4E38-8FA0-FA13B6134454}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98F3A2B7-5774-4E38-8FA0-FA13B6134454}.Release|Any CPU.Build.0 = Release|Any CPU {22690754-DCFB-4CD2-968D-239C1952B52C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {22690754-DCFB-4CD2-968D-239C1952B52C}.Debug|Any CPU.Build.0 = Debug|Any CPU {22690754-DCFB-4CD2-968D-239C1952B52C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -114,6 +124,10 @@ Global {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Debug|Any CPU.Build.0 = Debug|Any CPU {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Release|Any CPU.ActiveCfg = Release|Any CPU {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Release|Any CPU.Build.0 = Release|Any CPU + {A324CC70-36DD-4B38-9EC8-9069F6130FAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A324CC70-36DD-4B38-9EC8-9069F6130FAC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A324CC70-36DD-4B38-9EC8-9069F6130FAC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A324CC70-36DD-4B38-9EC8-9069F6130FAC}.Release|Any CPU.Build.0 = Release|Any CPU {E67CB6B9-6F42-4E63-9603-810B5B9FBF57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E67CB6B9-6F42-4E63-9603-810B5B9FBF57}.Debug|Any CPU.Build.0 = Debug|Any CPU {E67CB6B9-6F42-4E63-9603-810B5B9FBF57}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -154,14 +168,18 @@ Global {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Debug|Any CPU.Build.0 = Debug|Any CPU {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Release|Any CPU.ActiveCfg = Release|Any CPU {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Release|Any CPU.Build.0 = Release|Any CPU - {B97964EC-658A-4205-AA5A-BB814B191C35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B97964EC-658A-4205-AA5A-BB814B191C35}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B97964EC-658A-4205-AA5A-BB814B191C35}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B97964EC-658A-4205-AA5A-BB814B191C35}.Release|Any CPU.Build.0 = Release|Any CPU {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}.Debug|Any CPU.Build.0 = Debug|Any CPU {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}.Release|Any CPU.ActiveCfg = Release|Any CPU {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}.Release|Any CPU.Build.0 = Release|Any CPU + {E04EC969-2539-46E9-B918-8C8B7BEB8828}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E04EC969-2539-46E9-B918-8C8B7BEB8828}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E04EC969-2539-46E9-B918-8C8B7BEB8828}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E04EC969-2539-46E9-B918-8C8B7BEB8828}.Release|Any CPU.Build.0 = Release|Any CPU + {B97964EC-658A-4205-AA5A-BB814B191C35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B97964EC-658A-4205-AA5A-BB814B191C35}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B97964EC-658A-4205-AA5A-BB814B191C35}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B97964EC-658A-4205-AA5A-BB814B191C35}.Release|Any CPU.Build.0 = Release|Any CPU {ACED6271-2F75-4E3B-BB79-9D40B74D6A51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {ACED6271-2F75-4E3B-BB79-9D40B74D6A51}.Debug|Any CPU.Build.0 = Debug|Any CPU {ACED6271-2F75-4E3B-BB79-9D40B74D6A51}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -179,6 +197,7 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {98F3A2B7-5774-4E38-8FA0-FA13B6134454} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {22690754-DCFB-4CD2-968D-239C1952B52C} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {6F93ED1D-6475-46F2-A7DB-B2A5F9DB5A83} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {82B17C91-B96A-4290-A623-6867912A4C8E} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} @@ -189,6 +208,7 @@ Global {913C0212-58AC-42B7-B555-F96B8E287E7F} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {F46ADD04-B716-4E9B-9799-7C47DDDB08FC} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {A803DE4B-B050-48F2-82A1-8E947D8FB96C} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} + {A324CC70-36DD-4B38-9EC8-9069F6130FAC} = {815F0941-3B70-4705-A583-AF627559595C} {E67CB6B9-6F42-4E63-9603-810B5B9FBF57} = {815F0941-3B70-4705-A583-AF627559595C} {E28F4E8D-148B-4583-A27D-E1DA2CC08167} = {815F0941-3B70-4705-A583-AF627559595C} {55B3650C-36C6-4C05-B6A2-B4CBC3DC3E4C} = {815F0941-3B70-4705-A583-AF627559595C} @@ -199,8 +219,9 @@ Global {B82E2980-E145-4341-BAE0-8FAE1F110D0C} = {815F0941-3B70-4705-A583-AF627559595C} {526B37AD-256A-445A-9A42-E5C53989B11E} = {815F0941-3B70-4705-A583-AF627559595C} {978023EA-2ED5-4A28-96AD-4BB914EF2BE5} = {815F0941-3B70-4705-A583-AF627559595C} - {B97964EC-658A-4205-AA5A-BB814B191C35} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} + {E04EC969-2539-46E9-B918-8C8B7BEB8828} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} + {B97964EC-658A-4205-AA5A-BB814B191C35} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {ACED6271-2F75-4E3B-BB79-9D40B74D6A51} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {37ED98F0-3129-49B8-BF60-3BDEBBB34822} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {AC04C113-8F75-43BA-8FEE-987475A87C58} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} diff --git a/samples/AuthenticationSample/AuthenticationSample.csproj b/samples/AuthenticationSample/AuthenticationSample.csproj new file mode 100644 index 0000000..b293160 --- /dev/null +++ b/samples/AuthenticationSample/AuthenticationSample.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/samples/AuthenticationSample/Program.cs b/samples/AuthenticationSample/Program.cs new file mode 100644 index 0000000..c71be22 --- /dev/null +++ b/samples/AuthenticationSample/Program.cs @@ -0,0 +1,12 @@ +var builder = WebApplication.CreateBuilder(args); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.MapGet("/", async (context) => +{ + await context.Response.WriteAsync("Hello World!"); +}); + +app.Run(); diff --git a/samples/AuthenticationSample/Properties/launchSettings.json b/samples/AuthenticationSample/Properties/launchSettings.json new file mode 100644 index 0000000..82da3ac --- /dev/null +++ b/samples/AuthenticationSample/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:51551", + "sslPort": 44347 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "AuthenticationSample": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Tingle.AspNetCore.Authentication/AddressClaim.cs b/src/Tingle.AspNetCore.Authentication/AddressClaim.cs new file mode 100644 index 0000000..a4b777d --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/AddressClaim.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Serialization; + +namespace Tingle.AspNetCore.Authentication; + +/// +/// Represents the object encoded into the 'address' claim in JSON. +/// +public sealed record AddressClaim +{ + /// + /// Full mailing address, formatted for display or use on a mailing label. + /// This field MAY contain multiple lines, separated by newlines. + /// Newlines can be represented either as a carriage return/line feed pair (\r\n) + /// or as a single line feed character (\n). + /// + [JsonPropertyName("formatted")] + public string? Formatted { get; set; } + + /// + /// Full street address component, which MAY include house number, street name, + /// Post Office Box, and multi-line extended street address information. This + /// field MAY contain multiple lines, separated by newlines. Newlines can be + /// represented either as a carriage return/line feed pair (\r\n) or as a single + /// line feed character (\n) + /// + [JsonPropertyName("street_address")] + public string? StreetAddress { get; set; } + + /// + /// City or locality component. + /// + [JsonPropertyName("locality")] + public string? Locality { get; set; } + + /// + /// State, province, prefecture, or region component. + /// + [JsonPropertyName("region")] + public string? Region { get; set; } + + /// + /// Zip code or postal code component. + /// + [JsonPropertyName("postal_code")] + public string? PostalCode { get; set; } + + /// + /// Country name component. + /// + [JsonPropertyName("country")] + public string? Country { get; set; } +} diff --git a/src/Tingle.AspNetCore.Authentication/Constants.cs b/src/Tingle.AspNetCore.Authentication/Constants.cs new file mode 100644 index 0000000..aead308 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/Constants.cs @@ -0,0 +1,15 @@ +namespace Tingle.AspNetCore.Authentication; + +/// +internal static class Constants +{ + /// + /// The URI for a claim that specifies the object identifier of an entity, http://schemas.microsoft.com/identity/claims/objectidentifier. + /// + public const string ObjectIdentifierClaimType = "http://schemas.microsoft.com/identity/claims/objectidentifier"; + + /// + /// The URI for a claim that specifies the tenant identifier of an entity, http://schemas.microsoft.com/identity/claims/tenantid. + /// + public const string TenantIdClaimType = "http://schemas.microsoft.com/identity/claims/tenantid"; +} diff --git a/src/Tingle.AspNetCore.Authentication/CustomJsonSerializerContext.cs b/src/Tingle.AspNetCore.Authentication/CustomJsonSerializerContext.cs new file mode 100644 index 0000000..1a1f5b7 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/CustomJsonSerializerContext.cs @@ -0,0 +1,6 @@ +using System.Text.Json.Serialization; + +namespace Tingle.AspNetCore.Authentication; + +[JsonSerializable(typeof(AddressClaim))] +internal partial class CustomJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Tingle.AspNetCore.Authentication/Extensions/AuthenticationBuilderExtensions.PassThrough.cs b/src/Tingle.AspNetCore.Authentication/Extensions/AuthenticationBuilderExtensions.PassThrough.cs new file mode 100644 index 0000000..f8efa2c --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/Extensions/AuthenticationBuilderExtensions.PassThrough.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Tingle.AspNetCore.Authentication.PassThrough; + +namespace Microsoft.Extensions.DependencyInjection; + +public static partial class AuthenticationBuilderExtensions +{ + /// + /// Add authentication for PassThrough + /// + /// + /// + public static AuthenticationBuilder AddPassThrough(this AuthenticationBuilder builder) + => builder.AddPassThrough(PassThroughDefaults.AuthenticationScheme, _ => { }); + + /// + /// Add authentication for PassThrough + /// + /// + /// + /// + public static AuthenticationBuilder AddPassThrough(this AuthenticationBuilder builder, Action configureOptions) + => builder.AddPassThrough(PassThroughDefaults.AuthenticationScheme, configureOptions); + + /// + /// Add authentication for PassThrough + /// + /// + /// the name the scheme is to registered with + /// + /// + public static AuthenticationBuilder AddPassThrough(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) + => builder.AddPassThrough(authenticationScheme, displayName: null, configureOptions: configureOptions); + + /// + /// Add authentication for PassThrough + /// + /// + /// the name the scheme is to registered with + /// the name to be used when displaying the scheme + /// + /// + public static AuthenticationBuilder AddPassThrough(this AuthenticationBuilder builder, string authenticationScheme, string? displayName, Action configureOptions) + { + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PassThroughPostConfigureOptions>()); + return builder.AddScheme(authenticationScheme, displayName, configureOptions); + } +} diff --git a/src/Tingle.AspNetCore.Authentication/Extensions/AuthenticationBuilderExtensions.SharedKey.cs b/src/Tingle.AspNetCore.Authentication/Extensions/AuthenticationBuilderExtensions.SharedKey.cs new file mode 100644 index 0000000..3ad4ffd --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/Extensions/AuthenticationBuilderExtensions.SharedKey.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Tingle.AspNetCore.Authentication.SharedKey; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions on for SharedKey and PassThrough +/// +public static partial class AuthenticationBuilderExtensions +{ + /// + /// Add authentication for shared key + /// + /// + /// + public static AuthenticationBuilder AddSharedKey(this AuthenticationBuilder builder) + => builder.AddSharedKey(SharedKeyDefaults.AuthenticationScheme, _ => { }); + + /// + /// Add authentication for shared key + /// + /// + /// + /// + public static AuthenticationBuilder AddSharedKey(this AuthenticationBuilder builder, Action configureOptions) + => builder.AddSharedKey(SharedKeyDefaults.AuthenticationScheme, configureOptions); + + /// + /// Add authentication for shared key + /// + /// + /// the name the scheme is to registered with + /// + /// + public static AuthenticationBuilder AddSharedKey(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) + => builder.AddSharedKey(authenticationScheme, displayName: null, configureOptions: configureOptions); + + /// + /// Add authentication for shared key + /// + /// + /// the name the scheme is to registered with + /// the name to be used when displaying the scheme + /// + /// + public static AuthenticationBuilder AddSharedKey(this AuthenticationBuilder builder, string authenticationScheme, string? displayName, Action configureOptions) + { + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, SharedKeyPostConfigureOptions>()); + return builder.AddScheme(authenticationScheme, displayName, configureOptions); + } +} diff --git a/src/Tingle.AspNetCore.Authentication/Extensions/ClaimsPrincipalExtensions.cs b/src/Tingle.AspNetCore.Authentication/Extensions/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..834c136 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/Extensions/ClaimsPrincipalExtensions.cs @@ -0,0 +1,200 @@ +using Tingle.AspNetCore.Authentication; + +namespace System.Security.Claims; + +/// +/// Extension methods for +/// +public static class ClaimsPrincipalExtensions +{ + /// + /// Get the user's email using the email or type. + /// + /// + /// + public static string? GetEmail(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.FindFirstValue("email") ?? principal.FindFirstValue(ClaimTypes.Email); + } + + /// + /// Get if the user's email is verified using the 'email_verified' type. + /// + /// + /// + public static bool? GetEmailVerified(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + var value = principal.FindFirstValue("email_verified"); + if (bool.TryParse(value, out var result)) return result; + return null; + } + + /// + /// Get the User Principal Name (UPN) using the type. + /// + /// + /// + public static string? GetUpn(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.FindFirstValue(ClaimTypes.Upn); + } + + /// + /// Get the user's email or the UPN (User Principal Name) when the email is not found. + /// This uses the or type. + /// + /// + /// + public static string? GetEmailOrUpn(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.GetEmail() ?? principal.GetUpn(); + } + + /// + /// Get the user's preferred username using the 'preferred_username' type. + /// + /// + /// + public static string? GetPreferredUsername(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.FindFirstValue("preferred_username"); + } + + /// + /// Get the user's name using the 'name' or type. + /// + /// + /// + public static string? GetName(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.FindFirstValue("name") ?? principal.FindFirstValue(ClaimTypes.Name); + } + + /// + /// Get the entity's name identifier using the type. + /// + /// + /// The principal about which the token asserts information, such as the user of an app. This value is + /// immutable and cannot be reassigned or reused. The subject is a pairwise identifier - it is unique to a + /// particular application ID. If a single user signs into two different apps using two different client IDs, + /// those apps will receive two different values for the subject claim. This may or may not be wanted + /// depending on your architecture and privacy requirements. + /// + /// + /// + public static string? GetNameId(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.FindFirstValue(ClaimTypes.NameIdentifier); + } + + /// + /// Get the entity's identifier using the sub type. + /// + /// + /// + public static string? GetSub(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.FindFirstValue("sub"); + } + + /// + /// Get the entity's object identifier using the type. + /// + /// + /// The immutable identifier for an object in the Microsoft identity system, in this case, a user account. + /// This ID uniquely identifies the user across applications - two different applications signing in the + /// same user will receive the same value in the oid claim. The Microsoft Graph will return this ID as the + /// id property for a given user account. Because the oid allows multiple apps to correlate users, the profile + /// scope is required to receive this claim. Note that if a single user exists in multiple tenants, the user + /// will contain a different object ID in each tenant - they're considered different accounts, even though the + /// user logs into each account with the same credentials. The oid claim is a GUID and cannot be reused. + /// + /// + /// + public static string? GetObjectId(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.FindFirstValue(Constants.ObjectIdentifierClaimType); + } + + /// + /// Get the user's unique identifier. + /// This uses the or type. + /// + /// + /// + public static string? GetUserId(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.GetObjectId() ?? principal.GetNameId() ?? principal.GetSub(); + } + + /// + /// Get the identifier of the tenant for the user using the type. + /// + /// + /// + public static string? GetTenantId(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.FindFirstValue(Constants.TenantIdClaimType); + } + + /// + /// Get the user's phone number using the 'phone_number' type. + /// + /// + /// + public static string? GetPhoneNumber(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.FindFirstValue("phone_number"); + } + + /// + /// Get if the user's phone number is verified using the 'phone_number_verified' type. + /// + /// + /// + public static bool? GetPhoneNumberVerified(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + var value = principal.FindFirstValue("phone_number_verified"); + if (bool.TryParse(value, out var result)) return result; + return null; + } + + /// + /// Get the user's address using the 'address' type. + /// This is usually a JSON object. + /// + /// + /// + public static string? GetAddress(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + return principal.FindFirstValue("address"); + } + + /// + /// Get the user's address using the 'address' type. + /// This is usually a JSON object that is then decoded into an object + /// + /// + /// + public static AddressClaim? GetAddressDecoded(this ClaimsPrincipal principal) + { + if (principal == null) throw new ArgumentNullException(nameof(principal)); + var json = principal.GetAddress(); + if (string.IsNullOrWhiteSpace(json)) return default; + return Text.Json.JsonSerializer.Deserialize(json, CustomJsonSerializerContext.Default.AddressClaim); + } +} diff --git a/src/Tingle.AspNetCore.Authentication/Extensions/ILoggerExtensions.cs b/src/Tingle.AspNetCore.Authentication/Extensions/ILoggerExtensions.cs new file mode 100644 index 0000000..512bceb --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/Extensions/ILoggerExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Logging; + +namespace Tingle.AspNetCore.Authentication; + +internal static partial class ILoggerExtensions +{ + [LoggerMessage(1, LogLevel.Information, "Failed to validate the token.")] + public static partial void TokenValidationFailed(this ILogger logger, Exception ex); + + [LoggerMessage(2, LogLevel.Information, "Successfully validated the token.")] + public static partial void TokenValidationSucceeded(this ILogger logger); + + [LoggerMessage(3, LogLevel.Error, "Exception occurred while processing message.")] + public static partial void ErrorProcessingMessage(this ILogger logger, Exception ex); +} diff --git a/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughContext.cs b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughContext.cs new file mode 100644 index 0000000..2fe64c0 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughContext.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using System.Security.Claims; + +namespace Tingle.AspNetCore.Authentication.PassThrough; + +/// +/// Context used with +/// +public class PassThroughContext : ResultContext +{ + /// + /// Creates an instance of + /// + /// Claims Principal + /// Authentication properties + /// HttpContext + /// Authentication Scheme + /// options + public PassThroughContext(ClaimsPrincipal principal, + AuthenticationProperties properties, + HttpContext context, + AuthenticationScheme scheme, + PassThroughOptions options) + : base(context, scheme, options) + { + Principal = principal ?? throw new ArgumentNullException(nameof(principal)); + Properties = properties ?? throw new ArgumentNullException(nameof(properties)); + } +} diff --git a/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughDefaults.cs b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughDefaults.cs new file mode 100644 index 0000000..0d92c25 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughDefaults.cs @@ -0,0 +1,12 @@ +namespace Tingle.AspNetCore.Authentication.PassThrough; + +/// +/// Defaults for +/// +public static class PassThroughDefaults +{ + /// + /// Default authentication scheme for + /// + public const string AuthenticationScheme = "PassThrough"; +} diff --git a/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughEvents.cs b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughEvents.cs new file mode 100644 index 0000000..de881dd --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughEvents.cs @@ -0,0 +1,9 @@ +namespace Tingle.AspNetCore.Authentication.PassThrough; + +/// +/// Specifies events which the invokes to enable +/// developer control over the authentication process. +/// +public class PassThroughEvents +{ +} diff --git a/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughHandler.cs b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughHandler.cs new file mode 100644 index 0000000..dbb4304 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughHandler.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Text.Encodings.Web; + +namespace Tingle.AspNetCore.Authentication.PassThrough; + +/// +/// PassThrough authentication handler +/// +public class PassThroughHandler : AuthenticationHandler +{ + /// + /// Create an instance of + /// + /// + /// + /// + /// + public PassThroughHandler(IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) : base(options, logger, encoder, clock) { } + + /// + /// The handler calls methods on the events which give the application control at certain points where processing is occurring. + /// If it is not provided a default instance is supplied which does nothing when the methods are called. + /// + protected new PassThroughEvents Events + { + get { return (PassThroughEvents)base.Events!; } + set { base.Events = value; } + } + + /// + /// Do actual authentication work + /// + /// + protected override Task HandleAuthenticateAsync() + { + var identity = new ClaimsIdentity(Options.ClaimsIssuer); + var properties = new AuthenticationProperties(); + + var principal = new ClaimsPrincipal(identity); + var context = new PassThroughContext(principal: principal, + properties: properties, + context: Context, + scheme: Scheme, + options: Options); + + var ticket = new AuthenticationTicket(principal: principal, + properties: context.Properties, + authenticationScheme: Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughOptions.cs b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughOptions.cs new file mode 100644 index 0000000..577c293 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughOptions.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authentication; + +namespace Tingle.AspNetCore.Authentication.PassThrough; + +/// +/// Authentication options for +/// +public class PassThroughOptions : AuthenticationSchemeOptions +{ + /// + /// The object provided by the application to process events raised by the pass through authentication handler. + /// The application may implement the interface fully, or it may create an instance of + /// and assign delegates only to the events it wants to process. + /// + public new PassThroughEvents Events + { + get { return (PassThroughEvents)base.Events!; } + set { base.Events = value; } + } +} diff --git a/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughPostConfigureOptions.cs b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughPostConfigureOptions.cs new file mode 100644 index 0000000..a4d9594 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/PassThrough/PassThroughPostConfigureOptions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Options; + +namespace Tingle.AspNetCore.Authentication.PassThrough; + +/// +/// PassThrough post configure options +/// +internal class PassThroughPostConfigureOptions : IPostConfigureOptions +{ + public void PostConfigure(string? name, PassThroughOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + options.Events ??= new PassThroughEvents(); + } +} diff --git a/src/Tingle.AspNetCore.Authentication/README.md b/src/Tingle.AspNetCore.Authentication/README.md new file mode 100644 index 0000000..cdd179c --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/README.md @@ -0,0 +1,83 @@ +# Tingle.AspNetCore.Authentication + +## SharedKey Authentication + +This authentication logic mirrors the one for most Azure services such as Azure storage. Every request provides a different authentication value signed/encrypted with a key shared prioir. +This can often provide better security as compared to bearer tokens in OAuth/OpenId when introspection is not done on every request or when introspection canbe very expensive. + +Most common usage scenario is machine-to-machine authentication where the OAuth flow is expensive (introspection and renewing tokens). + +A token is generated based on the HTTP method, path, time (if you want to enforce time constraints), content length and content type, then hashed using a pre-shared key (such as the primary or secondary key). +The hashing algorithm used to generate the token is `HMACSHA256`. + +A sample request to a resource that does shared key authentication: +`curl -H "Authorization: SharedKey 1/fFAGRNJru1FTz70BzhT3Zg" https://api.contoso.com/v1/cars?type=tesla` + +Add the following logic to Program.cs file + +```cs +// Configure authentication +builder.Services.AddAuthentication() + .AddSharedKey(options => + { + options.ValidationParameters.KeysResolver = (ctx) => + { + var key1 = "my_primary_key_here"; + var key2 = "my_secondary_key_here"; + return Task.FromResult((IEnumerable)new[] { key1, key2, /* add as many keys as you wish */ }); + }; + }) + +builder.Service.AddAuthorization(options => +{ + options.AddPolicy("MyTenantAuthorizationPolicy", policy => + { + policy.AddAuthenticationSchemes(SharedKeyDefaults.AuthenticationScheme) + .RequireAuthenticatedUser(); + }); +} + +var app = builder.Build(); + +// Add auth middleware +app.UseAuthentication(); +app.UseAuthorization(); +``` + +`options` are of type `SharedKeyOptions`. They contain authentication options used by `SharedKeyTokenHandler`. You can modify some of these configurations to suit your own application needs. + +Now, we can use this functionality to authorize access to a controller as shown below: + +```cs +[Authorize("MyTenantAuthorizationPolicy")] +public class DummyController : ControllerBase {} +``` + +## Pass Through Authentication + +This authentication scheme results in a successful authentication result by default. You can then use this authentication result to perform authorization to access various endpoints depending on their authorization requirements e.g by restricting the range of IP addresses. + +Add the following logic in Program.cs file: + +```cs +builder.Services.AddAuthentication() + .AddPassThrough("my_scheme", null); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("myProcessAuthPolicy", policy => + { + policy.RequireAuthenticatedUser() + .AddAuthenticationSchemes("my_scheme"); + }); +}); + +public void Configure(IApplicationBuilder app, IHostingEnvironment env) +{ + ... + // Add auth middleware + app.UseAuthentication(); + app.UseAuthorization(); + .... +} +``` diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/AuthenticationFailedContext.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/AuthenticationFailedContext.cs new file mode 100644 index 0000000..f96a6cc --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/AuthenticationFailedContext.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Tingle.AspNetCore.Authentication.SharedKey; + +/// +/// Context used for +/// +public class AuthenticationFailedContext : ResultContext +{ + /// + /// Creates an instance of + /// + /// + /// + /// + public AuthenticationFailedContext(HttpContext context, AuthenticationScheme scheme, SharedKeyOptions options) + : base(context, scheme, options) + { + } + + /// + /// The exception describing the failure + /// + public Exception Exception { get; set; } = default!; +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/ForbiddenContext.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/ForbiddenContext.cs new file mode 100644 index 0000000..78a7991 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/ForbiddenContext.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Tingle.AspNetCore.Authentication.SharedKey; + +/// +/// Context used for +/// +public class ForbiddenContext : ResultContext +{ + /// + /// Creates and instance of + /// + /// + /// + /// + public ForbiddenContext(HttpContext context, AuthenticationScheme scheme, SharedKeyOptions options) + : base(context, scheme, options) { } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/MessageReceivedContext.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/MessageReceivedContext.cs new file mode 100644 index 0000000..823d665 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/MessageReceivedContext.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Tingle.AspNetCore.Authentication.SharedKey; + +/// +/// Context used with +/// +public class MessageReceivedContext : ResultContext +{ + /// + /// Creates an instance of + /// + /// + /// + /// + public MessageReceivedContext(HttpContext context, AuthenticationScheme scheme, SharedKeyOptions options) + : base(context, scheme, options) + { + } + + /// + /// The provided Token. This will give application an opportunity to retrieve token from an alternation location. + /// + public string? Token { get; set; } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyChallengeContext.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyChallengeContext.cs new file mode 100644 index 0000000..be92428 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyChallengeContext.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Tingle.AspNetCore.Authentication.SharedKey; + +/// +/// Context used with +/// +public class SharedKeyChallengeContext : PropertiesContext +{ + /// + /// Creates an instance of + /// + /// + /// + /// + /// + public SharedKeyChallengeContext(HttpContext context, + AuthenticationScheme scheme, + SharedKeyOptions options, + AuthenticationProperties properties) + : base(context, scheme, options, properties) { } + + /// + /// Any failures encountered during the authentication process. + /// + public Exception? AuthenticateFailure { get; set; } + + /// + /// Gets or sets the "error" value returned to the caller as part + /// of the WWW-Authenticate header. This property may be null when + /// is set to false. + /// + public string? Error { get; set; } + + /// + /// Gets or sets the "error_description" value returned to the caller as part + /// of the WWW-Authenticate header. This property may be null when + /// is set to false. + /// + public string? ErrorDescription { get; set; } + + /// + /// Gets or sets the "error_uri" value returned to the caller as part of the + /// WWW-Authenticate header. This property is always null unless explicitly set. + /// + public string? ErrorUri { get; set; } + + /// + /// If true, will skip any default logic for this challenge. + /// + public bool Handled { get; private set; } + + /// + /// Skips any default logic for this challenge. + /// + public void HandleResponse() => Handled = true; +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyDefaults.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyDefaults.cs new file mode 100644 index 0000000..8102ac9 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyDefaults.cs @@ -0,0 +1,22 @@ +namespace Tingle.AspNetCore.Authentication.SharedKey; + +/// +/// Defaults for +/// +public static class SharedKeyDefaults +{ + /// + /// Default authentication scheme for + /// + public const string AuthenticationScheme = "SharedKey"; + + /// + /// Default value for + /// + public const string HeaderPrefix = "SharedKey"; + + /// + /// Default value for + /// + public const string DateHeaderName = "x-ts-date"; +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyEvents.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyEvents.cs new file mode 100644 index 0000000..a426e6e --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyEvents.cs @@ -0,0 +1,44 @@ +namespace Tingle.AspNetCore.Authentication.SharedKey; + +/// +/// Specifies events which the invokes to enable developer control over the authentication process. +/// +public class SharedKeyEvents +{ + /// + /// Invoked if exceptions are thrown during request processing. The exceptions will be re-thrown after this event unless suppressed. + /// + public Func OnAuthenticationFailed { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked if Authorization fails and results in a Forbidden response + /// + public Func OnForbidden { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked when a protocol message is first received. + /// + public Func OnMessageReceived { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked after the security token has passed validation and a ClaimsIdentity has been generated. + /// + public Func OnTokenValidated { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked before a challenge is sent back to the caller. + /// + public Func OnChallenge { get; set; } = context => Task.CompletedTask; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + public virtual Task AuthenticationFailed(AuthenticationFailedContext context) => OnAuthenticationFailed(context); + + public virtual Task Forbidden(ForbiddenContext context) => OnForbidden(context); + + public virtual Task MessageReceived(MessageReceivedContext context) => OnMessageReceived(context); + + public virtual Task TokenValidated(TokenValidatedContext context) => OnTokenValidated(context); + + public virtual Task Challenge(SharedKeyChallengeContext context) => OnChallenge(context); +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyHandler.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyHandler.cs new file mode 100644 index 0000000..d80d82e --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyHandler.cs @@ -0,0 +1,297 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using System.Globalization; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using Tingle.AspNetCore.Authentication.SharedKey.Validation; +using Tingle.AspNetCore.Authentication.SharedKey.Validation.Exceptions; + +namespace Tingle.AspNetCore.Authentication.SharedKey; + +/// +/// Shared key authentication handler +/// +public class SharedKeyHandler : AuthenticationHandler +{ + /// + /// Create an instance of + /// + /// + /// + /// + /// + public SharedKeyHandler(IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) : base(options, logger, encoder, clock) { } + + /// + /// The handler calls methods on the events which give the application control at certain points where processing is occurring. + /// If it is not provided a default instance is supplied which does nothing when the methods are called. + /// + protected new SharedKeyEvents Events + { + get { return (SharedKeyEvents)base.Events!; } + set { base.Events = value; } + } + + /// + protected override Task CreateEventsAsync() => Task.FromResult(new SharedKeyEvents()); + + /// + protected override async Task HandleAuthenticateAsync() + { + try + { + // Give application opportunity to find from a different location, adjust, or reject token + var messageReceivedContext = new MessageReceivedContext(Context, Scheme, Options); + + // event can set the token + await Events.MessageReceived(messageReceivedContext).ConfigureAwait(false); + if (messageReceivedContext.Result != null) + { + return messageReceivedContext.Result; + } + + // If application retrieved token from somewhere else, use that. + var token = messageReceivedContext.Token; + + if (string.IsNullOrEmpty(token)) + { + string authorization = Request.Headers.Authorization.ToString(); + + // If no authorization header found nothing to process further + if (string.IsNullOrEmpty(authorization)) + { + return AuthenticateResult.NoResult(); + } + + // if we have a header and it starts with the prefix then extract the token + var prefix = $"{Options.HeaderValuePrefix} "; + if (authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + token = authorization[prefix.Length..].Trim(); + } + + // If no token found, no further work possible + if (string.IsNullOrEmpty(token)) + { + return AuthenticateResult.NoResult(); + } + } + + var validationParameters = Options.ValidationParameters; + + var validationFailures = new List(); + SharedKeyValidatedToken? validatedToken = null; + foreach (var validator in Options.TokenValidators) + { + if (validator.CanReadToken(token)) + { + ClaimsPrincipal principal; + try + { + (principal, validatedToken) = await validator.ValidateTokenAsync(token, Context, Options).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.TokenValidationFailed(ex); + validationFailures.Add(ex); + continue; + } + + Logger.TokenValidationSucceeded(); + + var tokenValidatedContext = new TokenValidatedContext(Context, Scheme, Options) + { + Principal = principal, + ValidationResponse = validatedToken, + }; + + await Events.TokenValidated(tokenValidatedContext).ConfigureAwait(false); + if (tokenValidatedContext.Result != null) + { + return tokenValidatedContext.Result; + } + + if (Options.SaveToken) + { + tokenValidatedContext.Properties.StoreTokens(new[] + { + new AuthenticationToken { Name = "access_token", Value = token } + }); + } + + tokenValidatedContext.Success(); + return tokenValidatedContext.Result!; + } + } + + if (validationFailures.Any()) + { + var authenticationFailedContext = new AuthenticationFailedContext(Context, Scheme, Options) + { + Exception = (validationFailures.Count == 1) ? validationFailures[0] : new AggregateException(validationFailures) + }; + + await Events.AuthenticationFailed(authenticationFailedContext).ConfigureAwait(false); + if (authenticationFailedContext.Result != null) + { + return authenticationFailedContext.Result; + } + + return AuthenticateResult.Fail(authenticationFailedContext.Exception); + } + + return AuthenticateResult.Fail("No SharedKeyTokenValidator available for token: " + token ?? "[null]"); + } + catch (Exception ex) + { + Logger.ErrorProcessingMessage(ex); + + var authenticationFailedContext = new AuthenticationFailedContext(Context, Scheme, Options) + { + Exception = ex + }; + + await Events.AuthenticationFailed(authenticationFailedContext).ConfigureAwait(false); + if (authenticationFailedContext.Result != null) + { + return authenticationFailedContext.Result; + } + + throw; + } + } + + /// + protected override async Task HandleChallengeAsync(AuthenticationProperties properties) + { + var authResult = await HandleAuthenticateOnceSafeAsync().ConfigureAwait(false); + var eventContext = new SharedKeyChallengeContext(Context, Scheme, Options, properties) + { + AuthenticateFailure = authResult?.Failure + }; + + // Avoid returning error=invalid_token if the error is not caused by an authentication failure (e.g missing token). + if (Options.IncludeErrorDetails && eventContext.AuthenticateFailure != null) + { + eventContext.Error = "invalid_token"; + eventContext.ErrorDescription = CreateErrorDescription(eventContext.AuthenticateFailure); + } + + await Events.Challenge(eventContext).ConfigureAwait(false); + if (eventContext.Handled) + { + return; + } + + Response.StatusCode = 401; + + if (string.IsNullOrEmpty(eventContext.Error) && + string.IsNullOrEmpty(eventContext.ErrorDescription) && + string.IsNullOrEmpty(eventContext.ErrorUri)) + { + Response.Headers.Append(HeaderNames.WWWAuthenticate, Options.Challenge); + } + else + { + // https://tools.ietf.org/html/rfc6750#section-3.1 + // WWW-Authenticate: Bearer realm="example", error="invalid_token", error_description="The access token expired" + var builder = new StringBuilder(Options.Challenge); + if (Options.Challenge.IndexOf(' ') > 0) + { + // Only add a comma after the first param, if any + builder.Append(','); + } + if (!string.IsNullOrEmpty(eventContext.Error)) + { + builder.Append(" error=\""); + builder.Append(eventContext.Error); + builder.Append('"'); + } + if (!string.IsNullOrEmpty(eventContext.ErrorDescription)) + { + if (!string.IsNullOrEmpty(eventContext.Error)) + { + builder.Append(','); + } + + builder.Append(" error_description=\""); + builder.Append(eventContext.ErrorDescription); + builder.Append('\"'); + } + if (!string.IsNullOrEmpty(eventContext.ErrorUri)) + { + if (!string.IsNullOrEmpty(eventContext.Error) || + !string.IsNullOrEmpty(eventContext.ErrorDescription)) + { + builder.Append(','); + } + + builder.Append(" error_uri=\""); + builder.Append(eventContext.ErrorUri); + builder.Append('\"'); + } + + Response.Headers.Append(HeaderNames.WWWAuthenticate, builder.ToString()); + } + } + + /// + protected override Task HandleForbiddenAsync(AuthenticationProperties properties) + { + var forbiddenContext = new ForbiddenContext(Context, Scheme, Options); + Response.StatusCode = 403; + return Events.Forbidden(forbiddenContext); + } + + private static string CreateErrorDescription(Exception authFailure) + { + IEnumerable exceptions; + if (authFailure is AggregateException agEx) + { + exceptions = agEx.InnerExceptions; + } + else + { + exceptions = new[] { authFailure }; + } + + var messages = new List(); + + foreach (var ex in exceptions) + { + // Order sensitive, some of these exceptions derive from others + // and we want to display the most specific message possible. + switch (ex) + { + case SharedKeyInvalidDateException skid: + messages.Add($"The supplied date is invalid; Supplied: '{skid.Value ?? "(null)"}'"); + break; + case SharedKeyTimeWindowExpiredException sktwe: + messages.Add($"The supplied date is invalid; NotOlderThan: '{sktwe.OldestAllowed.ToString(CultureInfo.InvariantCulture)}'" + + $", Supplied: '{sktwe.SuppliedTime.ToString(CultureInfo.InvariantCulture)}'"); + break; + case SharedKeyInvalidSignatureException _: + messages.Add("The signature is invalid"); + break; + case SharedKeyNoDateException sknd: + messages.Add($"Date header must be supplied in any of these headers ({sknd.HeaderNamesJoined})"); + break; + case SharedKeyNoKeysException _: + messages.Add("Unable to resolve signing keys"); + break; + case SharedKeyInvalidSigningKeysException _: + messages.Add("Invalid signing keys"); + break; + } + } + + return string.Join("; ", messages); + } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyOptions.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyOptions.cs new file mode 100644 index 0000000..5149dab --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyOptions.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Authentication; +using Tingle.AspNetCore.Authentication.SharedKey.Validation; + +namespace Tingle.AspNetCore.Authentication.SharedKey; + +/// +/// Authentication options for +/// +public class SharedKeyOptions : AuthenticationSchemeOptions +{ + /// + /// Gets or sets the challenge to put in the "WWW-Authenticate" header. + /// + public string Challenge { get; set; } = SharedKeyDefaults.AuthenticationScheme; + + /// + /// The object provided by the application to process events raised by the shared key authentication handler. + /// The application may implement the interface fully, or it may create an instance of + /// and assign delegates only to the events it wants to process. + /// + public new SharedKeyEvents Events + { + get { return (SharedKeyEvents)base.Events!; } + set { base.Events = value; } + } + + /// + /// The scheme for the authorization header e.g. Bearer, SharedKey + /// + public string HeaderValuePrefix { get; set; } = SharedKeyDefaults.HeaderPrefix; + + /// + /// Gets the ordered list of used to validate access tokens. + /// + public IList TokenValidators { get; } = new List { new SharedKeyTokenHandler() }; + + /// + /// Gets or sets the parameters used to validate tokens. + /// + /// Contains the types and definitions required for validating a token. + /// if 'value' is null. + public SharedKeyTokenValidationParameters ValidationParameters { get; set; } = new SharedKeyTokenValidationParameters(); + + /// + /// Defines whether the bearer token should be stored in the + /// after a successful authorization. + /// Defaults to + /// + public bool SaveToken { get; set; } = true; + + /// + /// Defines whether the token validation errors should be returned to the caller. + /// Enabled by default, this option can be disabled to prevent the JWT handler + /// from returning an error and an error_description in the WWW-Authenticate header. + /// + public bool IncludeErrorDetails { get; set; } = true; +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyPostConfigureOptions.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyPostConfigureOptions.cs new file mode 100644 index 0000000..7d0eeba --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/SharedKeyPostConfigureOptions.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Options; + +namespace Tingle.AspNetCore.Authentication.SharedKey; + +/// +/// SharedKey post configure options +/// +internal class SharedKeyPostConfigureOptions : IPostConfigureOptions +{ + public void PostConfigure(string? name, SharedKeyOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + options.Events ??= new SharedKeyEvents(); + + if (string.IsNullOrWhiteSpace(options.HeaderValuePrefix)) + throw new ArgumentNullException(nameof(options.HeaderValuePrefix)); + + if (options.ValidationParameters.DateHeaderNames == null || options.ValidationParameters.DateHeaderNames.Count < 1) + { + throw new InvalidOperationException($"{nameof(options.ValidationParameters.DateHeaderNames)} must have at least one value"); + } + + if (options.ValidationParameters.KeysResolver == null) + { + options.ValidationParameters.KeysResolver = (c) => Task.FromResult((IEnumerable)Array.Empty()); + } + + // if path prefix is specified, it must start with a '/' + if (!string.IsNullOrWhiteSpace(options.ValidationParameters.PathPrefix) && !options.ValidationParameters.PathPrefix.StartsWith("/")) + { + throw new ArgumentException($"{nameof(options.ValidationParameters.PathPrefix)} must start with '/' or be null", + nameof(options.ValidationParameters.PathPrefix)); + } + } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/TokenValidatedContext.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/TokenValidatedContext.cs new file mode 100644 index 0000000..e9a06bd --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/TokenValidatedContext.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Tingle.AspNetCore.Authentication.SharedKey.Validation; + +namespace Tingle.AspNetCore.Authentication.SharedKey; + +/// +/// Context used to call +/// +public class TokenValidatedContext : ResultContext +{ + /// + /// Creates an instance of + /// + /// + /// + /// + public TokenValidatedContext(HttpContext context, AuthenticationScheme scheme, SharedKeyOptions options) + : base(context, scheme, options) + { + } + + /// + /// The validation response + /// + public SharedKeyValidatedToken ValidationResponse { get; set; } = default!; +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidDateException.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidDateException.cs new file mode 100644 index 0000000..f51bd06 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidDateException.cs @@ -0,0 +1,32 @@ +using System.Runtime.Serialization; + +namespace Tingle.AspNetCore.Authentication.SharedKey.Validation.Exceptions; + +/// +[Serializable] +public class SharedKeyInvalidDateException : Exception +{ + /// + public SharedKeyInvalidDateException() { } + + /// + public SharedKeyInvalidDateException(string message) : base(message) { } + + /// + public SharedKeyInvalidDateException(string message, Exception inner) : base(message, inner) { } + + /// + protected SharedKeyInvalidDateException(SerializationInfo info, StreamingContext context) : base(info, context) { } + + /// + /// The value supplied for date + /// + public string? Value { get; private set; } + + private const string MessageFormat = "The specified time header value ({0}) is invalid"; + /// + public static SharedKeyInvalidDateException Create(string value) + { + return new SharedKeyInvalidDateException(string.Format(MessageFormat, value)) { Value = value }; + } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidSignatureException.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidSignatureException.cs new file mode 100644 index 0000000..944eda0 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidSignatureException.cs @@ -0,0 +1,20 @@ +using System.Runtime.Serialization; + +namespace Tingle.AspNetCore.Authentication.SharedKey.Validation.Exceptions; + +/// +[Serializable] +public class SharedKeyInvalidSignatureException : Exception +{ + /// + public SharedKeyInvalidSignatureException() { } + + /// + public SharedKeyInvalidSignatureException(string message) : base(message) { } + + /// + public SharedKeyInvalidSignatureException(string message, Exception inner) : base(message, inner) { } + + /// + protected SharedKeyInvalidSignatureException(SerializationInfo info, StreamingContext context) : base(info, context) { } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidSigningKeysException.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidSigningKeysException.cs new file mode 100644 index 0000000..dacc99e --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyInvalidSigningKeysException.cs @@ -0,0 +1,33 @@ +using System.Runtime.Serialization; + +namespace Tingle.AspNetCore.Authentication.SharedKey.Validation.Exceptions; + +/// +[Serializable] +public class SharedKeyInvalidSigningKeysException : Exception +{ + /// + public SharedKeyInvalidSigningKeysException() { } + + /// + public SharedKeyInvalidSigningKeysException(string message) : base(message) { } + + /// + public SharedKeyInvalidSigningKeysException(string message, Exception inner) : base(message, inner) { } + + /// + protected SharedKeyInvalidSigningKeysException(SerializationInfo info, StreamingContext context) : base(info, context) { } + + /// + /// List of invalid keys + /// + public IEnumerable? Keys { get; private set; } + + private const string MessageFormat = "Invalid keys specified ({0})."; + /// + public static SharedKeyInvalidSigningKeysException Create(List keys) + { + var keysJoined = string.Join(", ", keys); + return new SharedKeyInvalidSigningKeysException(string.Format(MessageFormat, keysJoined)) { Keys = keys }; + } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyNoDateException.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyNoDateException.cs new file mode 100644 index 0000000..6f9c983 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyNoDateException.cs @@ -0,0 +1,39 @@ +using System.Runtime.Serialization; + +namespace Tingle.AspNetCore.Authentication.SharedKey.Validation.Exceptions; + +/// +[Serializable] +public class SharedKeyNoDateException : Exception +{ + /// + public SharedKeyNoDateException() { } + + /// + public SharedKeyNoDateException(string message) : base(message) { } + + /// + public SharedKeyNoDateException(string message, Exception inner) : base(message, inner) { } + + /// + protected SharedKeyNoDateException(SerializationInfo info, StreamingContext context) : base(info, context) { } + + /// + /// List of possible header names for specifying the time + /// + public IEnumerable PossibleHeaderNames { get; private set; } = new List(); + + internal string? HeaderNamesJoined { get; private set; } + + private const string MessageFormat = "The date header must be specified with any of these names ({0})"; + /// + public static SharedKeyNoDateException Create(IEnumerable possibleHeaderNames) + { + var headerNamesJoined = string.Join(", ", possibleHeaderNames); + return new SharedKeyNoDateException(string.Format(MessageFormat, headerNamesJoined)) + { + PossibleHeaderNames = possibleHeaderNames, + HeaderNamesJoined = headerNamesJoined, + }; + } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyNoKeysException.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyNoKeysException.cs new file mode 100644 index 0000000..79a9173 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyNoKeysException.cs @@ -0,0 +1,20 @@ +using System.Runtime.Serialization; + +namespace Tingle.AspNetCore.Authentication.SharedKey.Validation.Exceptions; + +/// +[Serializable] +public class SharedKeyNoKeysException : Exception +{ + /// + public SharedKeyNoKeysException() { } + + /// + public SharedKeyNoKeysException(string message) : base(message) { } + + /// + public SharedKeyNoKeysException(string message, Exception inner) : base(message, inner) { } + + /// + protected SharedKeyNoKeysException(SerializationInfo info, StreamingContext context) : base(info, context) { } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyTimeWindowExpiredException.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyTimeWindowExpiredException.cs new file mode 100644 index 0000000..390b08c --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyTimeWindowExpiredException.cs @@ -0,0 +1,41 @@ +using System.Runtime.Serialization; + +namespace Tingle.AspNetCore.Authentication.SharedKey.Validation.Exceptions; + +/// +[Serializable] +public class SharedKeyTimeWindowExpiredException : Exception +{ + /// + public SharedKeyTimeWindowExpiredException() { } + + /// + public SharedKeyTimeWindowExpiredException(string message) : base(message) { } + + /// + public SharedKeyTimeWindowExpiredException(string message, Exception inner) : base(message, inner) { } + + /// + protected SharedKeyTimeWindowExpiredException(SerializationInfo info, StreamingContext context) : base(info, context) { } + + /// + /// The date supplied + /// + public DateTimeOffset SuppliedTime { get; private set; } + + /// + /// The date that was expected to not be older than + /// + public DateTimeOffset OldestAllowed { get; private set; } + + private const string MessageFormat = "The specified time {0} should not be after {1}"; + /// + public static SharedKeyTimeWindowExpiredException Create(DateTimeOffset suppliedTime, DateTimeOffset oldestAllowed) + { + return new SharedKeyTimeWindowExpiredException(string.Format(MessageFormat, suppliedTime, oldestAllowed)) + { + SuppliedTime = suppliedTime, + OldestAllowed = oldestAllowed + }; + } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyTokenException.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyTokenException.cs new file mode 100644 index 0000000..38fa662 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/Exceptions/SharedKeyTokenException.cs @@ -0,0 +1,20 @@ +using System.Runtime.Serialization; + +namespace Tingle.AspNetCore.Authentication.SharedKey.Validation.Exceptions; + +/// +[Serializable] +public class SharedKeyTokenException : Exception +{ + /// + public SharedKeyTokenException() { } + + /// + public SharedKeyTokenException(string message) : base(message) { } + + /// + public SharedKeyTokenException(string message, Exception inner) : base(message, inner) { } + + /// + protected SharedKeyTokenException(SerializationInfo info, StreamingContext context) : base(info, context) { } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/ISharedKeyTokenValidator.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/ISharedKeyTokenValidator.cs new file mode 100644 index 0000000..9e22a3c --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/ISharedKeyTokenValidator.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Http; +using System.Security.Claims; + +namespace Tingle.AspNetCore.Authentication.SharedKey.Validation; + +/// +/// Validator for tokens generated with a shared key +/// +public interface ISharedKeyTokenValidator +{ + /// + /// Returns true if the token can be read, false otherwise. + /// + bool CanReadToken(string token); + + /// + /// Validates a token passed as a string using + /// + Task<(ClaimsPrincipal, SharedKeyValidatedToken)> ValidateTokenAsync(string securityToken, + HttpContext httpContext, + SharedKeyOptions options); +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyTokenHandler.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyTokenHandler.cs new file mode 100644 index 0000000..8c8f3df --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyTokenHandler.cs @@ -0,0 +1,140 @@ +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using System.Text; +using Tingle.AspNetCore.Authentication.SharedKey.Validation.Exceptions; + +namespace Tingle.AspNetCore.Authentication.SharedKey.Validation; + +/// +public class SharedKeyTokenHandler : ISharedKeyTokenValidator +{ + /// + public virtual bool CanReadToken(string token) => IsValidBase64(token); + + /// + public virtual async Task<(ClaimsPrincipal, SharedKeyValidatedToken)> ValidateTokenAsync(string securityToken, + HttpContext httpContext, + SharedKeyOptions options) + { + var validationParameters = options.ValidationParameters; + var httpRequest = httpContext.Request; + (var timeHeaderName, var timeHeaderValue) = FindDateHeader(httpRequest, validationParameters); + + // ensure we have a value for time + if (string.IsNullOrWhiteSpace(timeHeaderValue)) + { + throw SharedKeyNoDateException.Create(validationParameters.DateHeaderNames); + } + + // check time if allowed + if (validationParameters.TimeAllowance.HasValue) + { + // ensure we can parse + if (!DateTimeOffset.TryParse(timeHeaderValue, out DateTimeOffset time)) + { + throw SharedKeyInvalidDateException.Create(timeHeaderValue); + } + + // calculate the oldest date allowed + var oldestAllowed = DateTimeOffset.UtcNow.Subtract(validationParameters.TimeAllowance.Value); + + // ensure that the oldest allowed time is not after the provided time + if (oldestAllowed > time) + { + throw SharedKeyTimeWindowExpiredException.Create(time, oldestAllowed); + } + } + + // get possible signing keys, throw if there are none + var keys = await validationParameters.ResolveKeysAsync(httpContext).ConfigureAwait(false); + if (keys == null || !keys.Any()) + { + throw new SharedKeyNoKeysException("Keys could not be resolved"); + } + + // ensure the keys resolved are valid + var invalidKeys = keys.Where(k => !IsValidBase64(k)).ToList(); + if (invalidKeys.Count >= 1) + { + throw SharedKeyInvalidSigningKeysException.Create(invalidKeys); + } + + foreach (var key in keys) + { + var bytes = Convert.FromBase64String(key); + var expectedToken = MakeSignature(httpRequest, validationParameters, bytes, timeHeaderName, timeHeaderValue); + if (string.Equals(expectedToken, securityToken)) + { + // at this point, the authentication worked + var validatedToken = new SharedKeyValidatedToken(securityToken, key); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, validatedToken.MatchingKey), + new Claim(ClaimTypes.Hash, validatedToken.Token), + }; + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, options.ClaimsIssuer ?? "SharedKey")); + return (principal, validatedToken); + } + } + + // if we get here none of the keys worked so authentication failed + throw new SharedKeyInvalidSignatureException("The token provided token does not match any of the resolved keys"); + } + + private static string MakeSignature(HttpRequest httpRequest, + SharedKeyTokenValidationParameters validationParameters, + byte[] sharedKeyBytes, + string dateHeaderName, + string dateHeaderValue) + { + // collect items + var method = httpRequest.Method; + var contentLength = (int)(httpRequest.ContentLength ?? 0); + var contentType = httpRequest.ContentType; + dateHeaderValue ??= string.Empty; + + // append path where necessary + var resource = httpRequest.Path; + if (!string.IsNullOrWhiteSpace(validationParameters.PathPrefix)) + { + resource = validationParameters.PathPrefix + resource; + } + + var stringToHash = string.Join("\n", method, contentLength, contentType, $"{dateHeaderName}:{dateHeaderValue}", resource); + var bytesToHash = Encoding.ASCII.GetBytes(stringToHash); + using var sha256 = new System.Security.Cryptography.HMACSHA256(sharedKeyBytes); + var calculatedHash = sha256.ComputeHash(bytesToHash); + return Convert.ToBase64String(calculatedHash); + } + + private static bool IsValidBase64(string s) + { + if (string.IsNullOrWhiteSpace(s)) return false; + + try + { + _ = Convert.FromBase64String(s); + return true; + } + catch (Exception) + { + return false; + } + } + + private static (string key, string value) FindDateHeader(HttpRequest httpRequest, SharedKeyTokenValidationParameters validationParameters) + { + foreach (var possibleHeaderName in validationParameters.DateHeaderNames) + { + var v = httpRequest.Headers[possibleHeaderName]; + if (!string.IsNullOrEmpty(v)) + { + return (key: possibleHeaderName, value: v!); + } + } + + return default; + } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyTokenValidationParameters.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyTokenValidationParameters.cs new file mode 100644 index 0000000..3326cbe --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyTokenValidationParameters.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; + +namespace Tingle.AspNetCore.Authentication.SharedKey.Validation; + +/// +/// Validation parameters to use with +/// +public class SharedKeyTokenValidationParameters +{ + /// + /// The list of known api key values. These values are added to the results of + /// when checking for correct values. + /// + public ICollection KnownFixedKeys { get; set; } = new HashSet(); + + /// + /// The name of the headers from which we can extract a date that must be supplied in each incoming request + /// + public ICollection DateHeaderNames { get; set; } = new HashSet { SharedKeyDefaults.DateHeaderName, "x-ms-date", }; + + /// + /// The prefix to be applied to the path before computing signature for comparison. + /// Useful if the application is hosted in a folder e.g. '/app1'. + /// The value must begin with a '/'. + /// Leave null where not in use + /// + public string? PathPrefix { get; set; } + + /// + /// The amount of time allowed between now and the time the request was made as indicated in the date header + /// whose possible names are specified using . + /// Set to null to disable time allowance. Defaults to 5min + /// + public TimeSpan? TimeAllowance { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Resolver for signing keys + /// + public Func>> KeysResolver { get; set; } = (ctx) => Task.FromResult>(Array.Empty()); + + internal async Task> ResolveKeysAsync(HttpContext httpContext) + { + var keys = (await KeysResolver(httpContext).ConfigureAwait(false) ?? Array.Empty()).ToList(); + keys.AddRange(KnownFixedKeys); + return keys; + } +} diff --git a/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyValidatedToken.cs b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyValidatedToken.cs new file mode 100644 index 0000000..2142635 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/SharedKey/Validation/SharedKeyValidatedToken.cs @@ -0,0 +1,24 @@ +namespace Tingle.AspNetCore.Authentication.SharedKey.Validation; + +/// +/// The validation response produced after authentication +/// +public class SharedKeyValidatedToken +{ + /// + public SharedKeyValidatedToken(string token, string matchingKey) + { + Token = token ?? throw new ArgumentNullException(nameof(token)); + MatchingKey = matchingKey ?? throw new ArgumentNullException(nameof(matchingKey)); + } + + /// + /// The token parsed + /// + public string Token { get; internal set; } + + /// + /// The key that matched the signature specified by + /// + public string MatchingKey { get; internal set; } +} diff --git a/src/Tingle.AspNetCore.Authentication/Tingle.AspNetCore.Authentication.csproj b/src/Tingle.AspNetCore.Authentication/Tingle.AspNetCore.Authentication.csproj new file mode 100644 index 0000000..38888e4 --- /dev/null +++ b/src/Tingle.AspNetCore.Authentication/Tingle.AspNetCore.Authentication.csproj @@ -0,0 +1,11 @@ + + + + Convenience authentication functionality such as pass through and pre-shared key authentication mechanisms. + + + + + + + diff --git a/tests/Tingle.AspNetCore.Authentication.Tests/ClaimsPrincipalExtensionsTests.cs b/tests/Tingle.AspNetCore.Authentication.Tests/ClaimsPrincipalExtensionsTests.cs new file mode 100644 index 0000000..b055699 --- /dev/null +++ b/tests/Tingle.AspNetCore.Authentication.Tests/ClaimsPrincipalExtensionsTests.cs @@ -0,0 +1,284 @@ +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace Tingle.AspNetCore.Authentication.Tests; + +public class ClaimsPrincipalExtensionsTests +{ + const string ValidIssuer = "issuer.contoso.com"; + const string ValidAudience = "audience.contoso.com"; + + [Theory] + // resembles AzureAd token for user + [InlineData(null, + false, + "mburumaxwell@tingle.software", + "993143b6-3214-4bcb-8fd1-7782764d7858", + "J0-RjswRJ8bAIDSW99adHLN-keZUivIgCOMQcUstc8Y", + "Maxwell Weru", + null, + "80aeb978-f48b-4a62-a6d5-54b3d2a299d5", + null, + false)] + // resembles AzureAd token for Guest user + [InlineData("mburumaxwell@tingle.software", + false, + null, + "17c70c67-7c6f-43f0-8e72-b0dd23e0b7e9", + "tvmexzQHw_vNSWITn8iHlHnKySIl8mpplPph8zyjHIQ", + "Maxwell Weru", + null, + "b6a92e38-bf38-40ac-9704-779a9d9f6d18", + null, + false)] + // resembles Firebase token + [InlineData("mburumaxwell@gmail.com", + true, + null, + null, + "UjFwZHjtrdTTVXwMNG0N7m9KXah2", + "Maxwell Weru", + "mburumaxwell@gmail.com", + null, + "+254728837078", + true)] + public void PrincipalExtensionsWork(string expectedEmail, + bool expectedEmailVerified, + string expectedUpn, + string expectedObjectId, + string expectedNameId, + string expectedName, + string expectedPreferredUsername, + string expectedTenantId, + string expectedPhoneNumber, + bool expectedPhoneNumberVerified) + { + (var token, var key) = CreateStandardTokenAndKey(email: expectedEmail, + email_verified: expectedEmailVerified, + upn: expectedUpn, + objectId: expectedObjectId, + nameId: expectedNameId, + name: expectedName, + preferred_username: expectedPreferredUsername, + tenantId: expectedTenantId, + phone_number: expectedPhoneNumber, + phone_number_verified: expectedPhoneNumberVerified); + + var validationParameters = new TokenValidationParameters + { + ValidIssuer = ValidIssuer, + ValidAudience = ValidAudience, + IssuerSigningKey = key, + }; + + var validator = new JwtSecurityTokenHandler(); + var principal = validator.ValidateToken(token, validationParameters, out _); + Assert.NotNull(principal); + Assert.Equal(expectedEmail, principal.GetEmail()); + Assert.Equal(expectedEmailVerified, principal.GetEmailVerified()); + Assert.Equal(expectedUpn, principal.GetUpn()); + Assert.Equal(expectedObjectId, principal.GetObjectId()); + Assert.Equal(expectedNameId, principal.GetNameId()); + Assert.Equal(expectedName, principal.GetName()); + Assert.Equal(expectedPreferredUsername, principal.GetPreferredUsername()); + Assert.Equal(expectedTenantId, principal.GetTenantId()); + Assert.Equal(expectedPhoneNumber, principal.GetPhoneNumber()); + Assert.Equal(expectedPhoneNumberVerified, principal.GetPhoneNumberVerified()); + + // assert computed properties + Assert.Equal(expectedEmail ?? expectedUpn, principal.GetEmailOrUpn()); + Assert.Equal(expectedObjectId ?? expectedNameId, principal.GetUserId()); + } + + + private static (string token, SymmetricSecurityKey key) CreateStandardTokenAndKey(string email, + bool email_verified, + string upn, + string objectId, + string nameId, + string name, + string preferred_username, + string tenantId, + string phone_number, + bool phone_number_verified) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('a', 128))); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, nameId), + new Claim("name", name), + }; + + if (!string.IsNullOrEmpty(preferred_username)) + { + claims.Add(new Claim("preferred_username", preferred_username)); + } + + if (!string.IsNullOrEmpty(email)) + { + claims.Add(new Claim(ClaimTypes.Email, email)); + } + + claims.Add(new Claim("email_verified", email_verified.ToString())); + + if (!string.IsNullOrEmpty(upn)) + { + claims.Add(new Claim(ClaimTypes.Upn, upn)); + } + + if (!string.IsNullOrEmpty(objectId)) + { + claims.Add(new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", objectId)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + claims.Add(new Claim("http://schemas.microsoft.com/identity/claims/tenantid", tenantId)); + } + + if (!string.IsNullOrEmpty(phone_number)) + { + claims.Add(new Claim("phone_number", phone_number)); + } + + claims.Add(new Claim("phone_number_verified", phone_number_verified.ToString())); + + var token = new JwtSecurityToken(issuer: ValidIssuer, + audience: ValidAudience, + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: creds); + + var tokenText = new JwtSecurityTokenHandler().WriteToken(token); + return (tokenText, key); + } + + [Fact] + public void GetAddressDecoded_Works() + { + var addr_claim_value = "{\"street_address\": \"P.O.Box 20769\",\"postal_code\": \"00100\",\"locality\": \"Nairobi\",\"region\": null,\"country\": \"Kenya\"}"; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('a', 128))); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, "1234567890"), + new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", "1234567890"), + new Claim("address", addr_claim_value), + }; + + + var token = new JwtSecurityToken(issuer: ValidIssuer, + audience: ValidAudience, + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: creds); + + var tokenText = new JwtSecurityTokenHandler().WriteToken(token); + + var validationParameters = new TokenValidationParameters + { + ValidIssuer = ValidIssuer, + ValidAudience = ValidAudience, + IssuerSigningKey = key, + }; + + var validator = new JwtSecurityTokenHandler(); + var principal = validator.ValidateToken(tokenText, validationParameters, out _); + Assert.NotNull(principal); + Assert.Equal("1234567890", principal.GetNameId()); + Assert.Equal("1234567890", principal.GetObjectId()); + Assert.Equal(addr_claim_value, principal.GetAddress()); + var addr = principal.GetAddressDecoded(); + Assert.NotNull(addr); + Assert.Equal("P.O.Box 20769", addr?.StreetAddress); + Assert.Equal("Nairobi", addr?.Locality); + Assert.Null(addr?.Region); + Assert.Equal("00100", addr?.PostalCode); + Assert.Equal("Kenya", addr?.Country); + } + + [Fact] + public void GetAddressDecoded_IgnoresNull() + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('a', 128))); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, "1234567890"), + new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", "1234567890"), + }; + + + var token = new JwtSecurityToken(issuer: ValidIssuer, + audience: ValidAudience, + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: creds); + + var tokenText = new JwtSecurityTokenHandler().WriteToken(token); + + var validationParameters = new TokenValidationParameters + { + ValidIssuer = ValidIssuer, + ValidAudience = ValidAudience, + IssuerSigningKey = key, + }; + + var validator = new JwtSecurityTokenHandler(); + var principal = validator.ValidateToken(tokenText, validationParameters, out _); + Assert.NotNull(principal); + Assert.Equal("1234567890", principal.GetNameId()); + Assert.Equal("1234567890", principal.GetObjectId()); + Assert.Null(principal.GetAddress()); + var addr = principal.GetAddressDecoded(); + Assert.Null(addr); + } + + [Fact] + public void GetAddressDecoded_IgnoresWhitespace() + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('a', 128))); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, "1234567890"), + new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", "1234567890"), + new Claim("address", ""), + }; + + + var token = new JwtSecurityToken(issuer: ValidIssuer, + audience: ValidAudience, + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: creds); + + var tokenText = new JwtSecurityTokenHandler().WriteToken(token); + + var validationParameters = new TokenValidationParameters + { + ValidIssuer = ValidIssuer, + ValidAudience = ValidAudience, + IssuerSigningKey = key, + }; + + var validator = new JwtSecurityTokenHandler(); + var principal = validator.ValidateToken(tokenText, validationParameters, out _); + Assert.NotNull(principal); + Assert.Equal("1234567890", principal.GetNameId()); + Assert.Equal("1234567890", principal.GetObjectId()); + var addr_s = principal.GetAddress(); + Assert.NotNull(addr_s); + Assert.Empty(addr_s); + var addr = principal.GetAddressDecoded(); + Assert.Null(addr); + } +} diff --git a/tests/Tingle.AspNetCore.Authentication.Tests/PassThroughTests.cs b/tests/Tingle.AspNetCore.Authentication.Tests/PassThroughTests.cs new file mode 100644 index 0000000..c83710f --- /dev/null +++ b/tests/Tingle.AspNetCore.Authentication.Tests/PassThroughTests.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Tingle.AspNetCore.Authentication.PassThrough; + +namespace Tingle.AspNetCore.Authentication.Tests; + +public class PassThroughTests : SharedAuthenticationTests +{ + protected override string DefaultScheme => PassThroughDefaults.AuthenticationScheme; + protected override Type HandlerType => typeof(PassThroughHandler); + protected override bool SupportsSignIn { get => false; } + protected override bool SupportsSignOut { get => false; } + + protected override void RegisterAuth(AuthenticationBuilder services, Action configure) + { + services.AddPassThrough(o => + { + ConfigureDefaults(o); + configure.Invoke(o); + }); + } + + private void ConfigureDefaults(PassThroughOptions o) + { + } + +} diff --git a/tests/Tingle.AspNetCore.Authentication.Tests/SharedAuthenticationTests.cs b/tests/Tingle.AspNetCore.Authentication.Tests/SharedAuthenticationTests.cs new file mode 100644 index 0000000..10315fc --- /dev/null +++ b/tests/Tingle.AspNetCore.Authentication.Tests/SharedAuthenticationTests.cs @@ -0,0 +1,505 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using System.Security.Claims; + +#nullable disable // aligned with tests for inbuilt authentication + +namespace Tingle.AspNetCore.Authentication.Tests; + +public abstract class SharedAuthenticationTests where TOptions : AuthenticationSchemeOptions +{ + protected TestClock Clock { get; } = new TestClock(); + + protected abstract string DefaultScheme { get; } + protected virtual string DisplayName { get; } + protected abstract Type HandlerType { get; } + + protected virtual bool SupportsSignIn { get => true; } + protected virtual bool SupportsSignOut { get => true; } + + protected abstract void RegisterAuth(AuthenticationBuilder services, Action configure); + + [Fact] + public async Task CanForwardDefault() + { + var services = new ServiceCollection().AddLogging(); + + var builder = services.AddAuthentication(o => + { + o.DefaultScheme = DefaultScheme; + o.AddScheme("auth1", "auth1"); + }); + RegisterAuth(builder, o => o.ForwardDefault = "auth1"); + + var forwardDefault = new TestHandler(); + services.AddSingleton(forwardDefault); + + var sp = services.BuildServiceProvider(); + var context = new DefaultHttpContext(); + context.RequestServices = sp; + + Assert.Equal(0, forwardDefault.AuthenticateCount); + Assert.Equal(0, forwardDefault.ForbidCount); + Assert.Equal(0, forwardDefault.ChallengeCount); + Assert.Equal(0, forwardDefault.SignInCount); + Assert.Equal(0, forwardDefault.SignOutCount); + + await context.AuthenticateAsync(); + Assert.Equal(1, forwardDefault.AuthenticateCount); + + await context.ForbidAsync(); + Assert.Equal(1, forwardDefault.ForbidCount); + + await context.ChallengeAsync(); + Assert.Equal(1, forwardDefault.ChallengeCount); + + if (SupportsSignOut) + { + await context.SignOutAsync(); + Assert.Equal(1, forwardDefault.SignOutCount); + } + else + { + await Assert.ThrowsAsync(() => context.SignOutAsync()); + } + + if (SupportsSignIn) + { + await context.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity("whatever"))); + Assert.Equal(1, forwardDefault.SignInCount); + } + else + { + await Assert.ThrowsAsync(() => context.SignInAsync(new ClaimsPrincipal())); + } + } + + [Fact] + public async Task ForwardSignInWinsOverDefault() + { + if (SupportsSignIn) + { + var services = new ServiceCollection().AddLogging(); + + var builder = services.AddAuthentication(o => + { + o.DefaultScheme = DefaultScheme; + o.AddScheme("auth1", "auth1"); + o.AddScheme("specific", "specific"); + }); + RegisterAuth(builder, o => + { + o.ForwardDefault = "auth1"; + o.ForwardSignIn = "specific"; + }); + + var specific = new TestHandler(); + services.AddSingleton(specific); + var forwardDefault = new TestHandler2(); + services.AddSingleton(forwardDefault); + + var sp = services.BuildServiceProvider(); + var context = new DefaultHttpContext(); + context.RequestServices = sp; + + await context.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity("whatever"))); + Assert.Equal(1, specific.SignInCount); + Assert.Equal(0, specific.AuthenticateCount); + Assert.Equal(0, specific.ForbidCount); + Assert.Equal(0, specific.ChallengeCount); + Assert.Equal(0, specific.SignOutCount); + + Assert.Equal(0, forwardDefault.AuthenticateCount); + Assert.Equal(0, forwardDefault.ForbidCount); + Assert.Equal(0, forwardDefault.ChallengeCount); + Assert.Equal(0, forwardDefault.SignInCount); + Assert.Equal(0, forwardDefault.SignOutCount); + } + } + + [Fact] + public async Task ForwardSignOutWinsOverDefault() + { + if (SupportsSignOut) + { + var services = new ServiceCollection().AddLogging(); + var builder = services.AddAuthentication(o => + { + o.DefaultScheme = DefaultScheme; + o.AddScheme("auth1", "auth1"); + o.AddScheme("specific", "specific"); + }); + RegisterAuth(builder, o => + { + o.ForwardDefault = "auth1"; + o.ForwardSignOut = "specific"; + }); + + var specific = new TestHandler(); + services.AddSingleton(specific); + var forwardDefault = new TestHandler2(); + services.AddSingleton(forwardDefault); + + var sp = services.BuildServiceProvider(); + var context = new DefaultHttpContext(); + context.RequestServices = sp; + + await context.SignOutAsync(); + Assert.Equal(1, specific.SignOutCount); + Assert.Equal(0, specific.AuthenticateCount); + Assert.Equal(0, specific.ForbidCount); + Assert.Equal(0, specific.ChallengeCount); + Assert.Equal(0, specific.SignInCount); + + Assert.Equal(0, forwardDefault.AuthenticateCount); + Assert.Equal(0, forwardDefault.ForbidCount); + Assert.Equal(0, forwardDefault.ChallengeCount); + Assert.Equal(0, forwardDefault.SignInCount); + Assert.Equal(0, forwardDefault.SignOutCount); + } + } + + [Fact] + public async Task ForwardForbidWinsOverDefault() + { + var services = new ServiceCollection().AddLogging(); + var builder = services.AddAuthentication(o => + { + o.DefaultScheme = DefaultScheme; + o.AddScheme("auth1", "auth1"); + o.AddScheme("specific", "specific"); + }); + RegisterAuth(builder, o => + { + o.ForwardDefault = "auth1"; + o.ForwardForbid = "specific"; + }); + + var specific = new TestHandler(); + services.AddSingleton(specific); + var forwardDefault = new TestHandler2(); + services.AddSingleton(forwardDefault); + + var sp = services.BuildServiceProvider(); + var context = new DefaultHttpContext(); + context.RequestServices = sp; + + await context.ForbidAsync(); + Assert.Equal(0, specific.SignOutCount); + Assert.Equal(0, specific.AuthenticateCount); + Assert.Equal(1, specific.ForbidCount); + Assert.Equal(0, specific.ChallengeCount); + Assert.Equal(0, specific.SignInCount); + + Assert.Equal(0, forwardDefault.AuthenticateCount); + Assert.Equal(0, forwardDefault.ForbidCount); + Assert.Equal(0, forwardDefault.ChallengeCount); + Assert.Equal(0, forwardDefault.SignInCount); + Assert.Equal(0, forwardDefault.SignOutCount); + } + + [Fact] + public async Task ForwardAuthenticateWinsOverDefault() + { + var services = new ServiceCollection().AddLogging(); + var builder = services.AddAuthentication(o => + { + o.DefaultScheme = DefaultScheme; + o.AddScheme("auth1", "auth1"); + o.AddScheme("specific", "specific"); + }); + RegisterAuth(builder, o => + { + o.ForwardDefault = "auth1"; + o.ForwardAuthenticate = "specific"; + }); + + var specific = new TestHandler(); + services.AddSingleton(specific); + var forwardDefault = new TestHandler2(); + services.AddSingleton(forwardDefault); + + var sp = services.BuildServiceProvider(); + var context = new DefaultHttpContext(); + context.RequestServices = sp; + + await context.AuthenticateAsync(); + Assert.Equal(0, specific.SignOutCount); + Assert.Equal(1, specific.AuthenticateCount); + Assert.Equal(0, specific.ForbidCount); + Assert.Equal(0, specific.ChallengeCount); + Assert.Equal(0, specific.SignInCount); + + Assert.Equal(0, forwardDefault.AuthenticateCount); + Assert.Equal(0, forwardDefault.ForbidCount); + Assert.Equal(0, forwardDefault.ChallengeCount); + Assert.Equal(0, forwardDefault.SignInCount); + Assert.Equal(0, forwardDefault.SignOutCount); + } + + [Fact] + public async Task ForwardChallengeWinsOverDefault() + { + var services = new ServiceCollection().AddLogging(); + var builder = services.AddAuthentication(o => + { + o.DefaultScheme = DefaultScheme; + o.AddScheme("auth1", "auth1"); + o.AddScheme("specific", "specific"); + }); + RegisterAuth(builder, o => + { + o.ForwardDefault = "auth1"; + o.ForwardChallenge = "specific"; + }); + + var specific = new TestHandler(); + services.AddSingleton(specific); + var forwardDefault = new TestHandler2(); + services.AddSingleton(forwardDefault); + + var sp = services.BuildServiceProvider(); + var context = new DefaultHttpContext(); + context.RequestServices = sp; + + await context.ChallengeAsync(); + Assert.Equal(0, specific.SignOutCount); + Assert.Equal(0, specific.AuthenticateCount); + Assert.Equal(0, specific.ForbidCount); + Assert.Equal(1, specific.ChallengeCount); + Assert.Equal(0, specific.SignInCount); + + Assert.Equal(0, forwardDefault.AuthenticateCount); + Assert.Equal(0, forwardDefault.ForbidCount); + Assert.Equal(0, forwardDefault.ChallengeCount); + Assert.Equal(0, forwardDefault.SignInCount); + Assert.Equal(0, forwardDefault.SignOutCount); + } + + [Fact] + public async Task ForwardSelectorWinsOverDefault() + { + var services = new ServiceCollection().AddLogging(); + var builder = services.AddAuthentication(o => + { + o.DefaultScheme = DefaultScheme; + o.AddScheme("auth1", "auth1"); + o.AddScheme("selector", "selector"); + o.AddScheme("specific", "specific"); + }); + RegisterAuth(builder, o => + { + o.ForwardDefault = "auth1"; + o.ForwardDefaultSelector = _ => "selector"; + }); + + var specific = new TestHandler(); + services.AddSingleton(specific); + var forwardDefault = new TestHandler2(); + services.AddSingleton(forwardDefault); + var selector = new TestHandler3(); + services.AddSingleton(selector); + + var sp = services.BuildServiceProvider(); + var context = new DefaultHttpContext(); + context.RequestServices = sp; + + await context.AuthenticateAsync(); + Assert.Equal(1, selector.AuthenticateCount); + + await context.ForbidAsync(); + Assert.Equal(1, selector.ForbidCount); + + await context.ChallengeAsync(); + Assert.Equal(1, selector.ChallengeCount); + + if (SupportsSignOut) + { + await context.SignOutAsync(); + Assert.Equal(1, selector.SignOutCount); + } + else + { + await Assert.ThrowsAsync(() => context.SignOutAsync()); + } + + if (SupportsSignIn) + { + await context.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity("whatever"))); + Assert.Equal(1, selector.SignInCount); + } + else + { + await Assert.ThrowsAsync(() => context.SignInAsync(new ClaimsPrincipal())); + } + + Assert.Equal(0, forwardDefault.AuthenticateCount); + Assert.Equal(0, forwardDefault.ForbidCount); + Assert.Equal(0, forwardDefault.ChallengeCount); + Assert.Equal(0, forwardDefault.SignInCount); + Assert.Equal(0, forwardDefault.SignOutCount); + Assert.Equal(0, specific.AuthenticateCount); + Assert.Equal(0, specific.ForbidCount); + Assert.Equal(0, specific.ChallengeCount); + Assert.Equal(0, specific.SignInCount); + Assert.Equal(0, specific.SignOutCount); + } + + [Fact] + public async Task NullForwardSelectorUsesDefault() + { + var services = new ServiceCollection().AddLogging(); + var builder = services.AddAuthentication(o => + { + o.DefaultScheme = DefaultScheme; + o.AddScheme("auth1", "auth1"); + o.AddScheme("selector", "selector"); + o.AddScheme("specific", "specific"); + }); + RegisterAuth(builder, o => + { + o.ForwardDefault = "auth1"; + o.ForwardDefaultSelector = _ => null; + }); + + var specific = new TestHandler(); + services.AddSingleton(specific); + var forwardDefault = new TestHandler2(); + services.AddSingleton(forwardDefault); + var selector = new TestHandler3(); + services.AddSingleton(selector); + + var sp = services.BuildServiceProvider(); + var context = new DefaultHttpContext(); + context.RequestServices = sp; + + await context.AuthenticateAsync(); + Assert.Equal(1, forwardDefault.AuthenticateCount); + + await context.ForbidAsync(); + Assert.Equal(1, forwardDefault.ForbidCount); + + await context.ChallengeAsync(); + Assert.Equal(1, forwardDefault.ChallengeCount); + + if (SupportsSignOut) + { + await context.SignOutAsync(); + Assert.Equal(1, forwardDefault.SignOutCount); + } + else + { + await Assert.ThrowsAsync(() => context.SignOutAsync()); + } + + if (SupportsSignIn) + { + await context.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity("whatever"))); + Assert.Equal(1, forwardDefault.SignInCount); + } + else + { + await Assert.ThrowsAsync(() => context.SignInAsync(new ClaimsPrincipal())); + } + + Assert.Equal(0, selector.AuthenticateCount); + Assert.Equal(0, selector.ForbidCount); + Assert.Equal(0, selector.ChallengeCount); + Assert.Equal(0, selector.SignInCount); + Assert.Equal(0, selector.SignOutCount); + Assert.Equal(0, specific.AuthenticateCount); + Assert.Equal(0, specific.ForbidCount); + Assert.Equal(0, specific.ChallengeCount); + Assert.Equal(0, specific.SignInCount); + Assert.Equal(0, specific.SignOutCount); + } + + [Fact] + public async Task SpecificForwardWinsOverSelectorAndDefault() + { + var services = new ServiceCollection().AddLogging(); + var builder = services.AddAuthentication(o => + { + o.DefaultScheme = DefaultScheme; + o.AddScheme("auth1", "auth1"); + o.AddScheme("selector", "selector"); + o.AddScheme("specific", "specific"); + }); + RegisterAuth(builder, o => + { + o.ForwardDefault = "auth1"; + o.ForwardDefaultSelector = _ => "selector"; + o.ForwardAuthenticate = "specific"; + o.ForwardChallenge = "specific"; + o.ForwardSignIn = "specific"; + o.ForwardSignOut = "specific"; + o.ForwardForbid = "specific"; + }); + + var specific = new TestHandler(); + services.AddSingleton(specific); + var forwardDefault = new TestHandler2(); + services.AddSingleton(forwardDefault); + var selector = new TestHandler3(); + services.AddSingleton(selector); + + var sp = services.BuildServiceProvider(); + var context = new DefaultHttpContext(); + context.RequestServices = sp; + + await context.AuthenticateAsync(); + Assert.Equal(1, specific.AuthenticateCount); + + await context.ForbidAsync(); + Assert.Equal(1, specific.ForbidCount); + + await context.ChallengeAsync(); + Assert.Equal(1, specific.ChallengeCount); + + if (SupportsSignOut) + { + await context.SignOutAsync(); + Assert.Equal(1, specific.SignOutCount); + } + else + { + await Assert.ThrowsAsync(() => context.SignOutAsync()); + } + + if (SupportsSignIn) + { + await context.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity("whatever"))); + Assert.Equal(1, specific.SignInCount); + } + else + { + await Assert.ThrowsAsync(() => context.SignInAsync(new ClaimsPrincipal())); + } + + Assert.Equal(0, forwardDefault.AuthenticateCount); + Assert.Equal(0, forwardDefault.ForbidCount); + Assert.Equal(0, forwardDefault.ChallengeCount); + Assert.Equal(0, forwardDefault.SignInCount); + Assert.Equal(0, forwardDefault.SignOutCount); + Assert.Equal(0, selector.AuthenticateCount); + Assert.Equal(0, selector.ForbidCount); + Assert.Equal(0, selector.ChallengeCount); + Assert.Equal(0, selector.SignInCount); + Assert.Equal(0, selector.SignOutCount); + } + + [Fact] + public async Task VerifySchemeDefaults() + { + var services = new ServiceCollection(); + var builder = services.AddAuthentication(); + RegisterAuth(builder, o => { }); + var sp = services.BuildServiceProvider(); + var schemeProvider = sp.GetRequiredService(); + var scheme = await schemeProvider.GetSchemeAsync(DefaultScheme); + Assert.NotNull(scheme); + Assert.Equal(HandlerType, scheme.HandlerType); + Assert.Equal(DisplayName, scheme.DisplayName); + } +} diff --git a/tests/Tingle.AspNetCore.Authentication.Tests/SharedKeyTests.cs b/tests/Tingle.AspNetCore.Authentication.Tests/SharedKeyTests.cs new file mode 100644 index 0000000..91a068a --- /dev/null +++ b/tests/Tingle.AspNetCore.Authentication.Tests/SharedKeyTests.cs @@ -0,0 +1,1015 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using System.Globalization; +using System.Net; +using System.Reflection; +using System.Security.Claims; +using System.Text; +using System.Xml.Linq; +using Tingle.AspNetCore.Authentication.SharedKey; +using Tingle.AspNetCore.Authentication.SharedKey.Validation; +using Tingle.AspNetCore.Authentication.SharedKey.Validation.Exceptions; + +#nullable disable // aligned with tests for inbuilt authentication + +namespace Tingle.AspNetCore.Authentication.Tests; + +public class SharedKeyTests : SharedAuthenticationTests +{ + protected override string DefaultScheme => SharedKeyDefaults.AuthenticationScheme; + protected override Type HandlerType => typeof(SharedKeyHandler); + protected override bool SupportsSignIn { get => false; } + protected override bool SupportsSignOut { get => false; } + + protected override void RegisterAuth(AuthenticationBuilder services, Action configure) + { + services.AddSharedKey(o => + { + ConfigureDefaults(o); + configure.Invoke(o); + }); + } + + private void ConfigureDefaults(SharedKeyOptions o) + { + } + + [Fact] + public async Task SharedKeyTokenValidation() + { + var date = DateTimeOffset.UtcNow.ToString("r"); + (var tokenText, var key) = CreateStandardTokenAndKey(date, "/oauth"); + + var server = CreateServer(o => + { + o.ValidationParameters = new SharedKeyTokenValidationParameters + { + KnownFixedKeys = Array.Empty(), + KeysResolver = (ctx) => Task.FromResult((IEnumerable)new[] { key }) + }; + }); + + var newSharedKeyToken = "SharedKey " + tokenText; + var response = await SendAsync(server, "http://example.com/oauth", newSharedKeyToken, date); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + } + + [Fact] + public async Task SaveSharedKeyToken() + { + var date = DateTimeOffset.UtcNow.ToString("r"); + (var tokenText, var key) = CreateStandardTokenAndKey(date, "/token"); + + var server = CreateServer(o => + { + o.SaveToken = true; + o.ValidationParameters = new SharedKeyTokenValidationParameters + { + KnownFixedKeys = Array.Empty(), + KeysResolver = (ctx) => Task.FromResult((IEnumerable)new[] { key }) + }; + }); + + var newSharedKeyToken = "SharedKey " + tokenText; + var response = await SendAsync(server, "http://example.com/token", newSharedKeyToken, date); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal(tokenText, await response.Response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task SignInThrows() + { + var server = CreateServer(); + var transaction = await server.SendAsync("https://example.com/signIn"); + Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); + } + + [Fact] + public async Task SignOutThrows() + { + var server = CreateServer(); + var transaction = await server.SendAsync("https://example.com/signOut"); + Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); + } + + [Fact] + public async Task ThrowAtAuthenticationFailedEvent() + { + var server = CreateServer(o => + { + o.Events = new SharedKeyEvents + { + OnAuthenticationFailed = context => + { + context.Response.StatusCode = 401; + throw new Exception(); + }, + OnMessageReceived = context => + { + context.Token = "something"; + return Task.FromResult(0); + } + }; + }, + async (context, next) => + { + try + { + await next(); + Assert.False(true, "Expected exception is not thrown"); + } + catch (Exception) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsync("i got this"); + } + }); + + var transaction = await server.SendAsync("https://example.com/signIn"); + + Assert.Equal(HttpStatusCode.Unauthorized, transaction.Response.StatusCode); + } + + [Fact] + public async Task CustomHeaderReceived() + { + var server = CreateServer(o => + { + o.Events = new SharedKeyEvents() + { + OnMessageReceived = context => + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "Bob le Magnifique"), + new Claim(ClaimTypes.Email, "bob@contoso.com"), + new Claim(ClaimsIdentity.DefaultNameClaimType, "bob") + }; + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name)); + context.Success(); + + return Task.FromResult(null); + } + }; + }); + + var response = await SendAsync(server, "http://example.com/oauth", "someHeader someblob"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal("Bob le Magnifique", response.ResponseText); + } + + [Fact] + public async Task NoHeaderReceived() + { + var server = CreateServer(); + var response = await SendAsync(server, "http://example.com/oauth"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + } + + [Fact] + public async Task HeaderWithoutSharedKeyReceived() + { + var server = CreateServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Token"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + } + + [Fact] + public async Task UnrecognizedTokenReceived() + { + var server = CreateServer(); + var response = await SendAsync(server, "http://example.com/oauth", "SharedKey someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("", response.ResponseText); + } + + [Fact] + public async Task InvalidTokenReceived() + { + var server = CreateServer(options => + { + options.TokenValidators.Clear(); + options.TokenValidators.Add(new InvalidTokenValidator()); + }); + + var response = await SendAsync(server, "http://example.com/oauth", "SharedKey someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("SharedKey error=\"invalid_token\"", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Fact] + public async Task InvalidTokenReceived_NullKeys() + { + var date = DateTimeOffset.UtcNow.ToString("r"); + (var tokenText, var key) = CreateStandardTokenAndKey(date, "/oauth"); + + var server = CreateServer(o => + { + o.ValidationParameters = new SharedKeyTokenValidationParameters + { + KnownFixedKeys = Array.Empty(), + KeysResolver = (ctx) => Task.FromResult((IEnumerable)null) + }; + }); + + var newSharedKeyToken = "SharedKey " + tokenText; + var response = await SendAsync(server, "http://example.com/oauth", newSharedKeyToken, date); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("SharedKey error=\"invalid_token\", error_description=\"Unable to resolve signing keys\"", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Fact] + public async Task InvalidTokenReceived_NoKey() + { + var date = DateTimeOffset.UtcNow.ToString("r"); + (var tokenText, var key) = CreateStandardTokenAndKey(date, "/oauth"); + + var server = CreateServer(o => + { + o.ValidationParameters = new SharedKeyTokenValidationParameters + { + KnownFixedKeys = Array.Empty(), + KeysResolver = (ctx) => Task.FromResult((IEnumerable)Array.Empty()) + }; + }); + + var newSharedKeyToken = "SharedKey " + tokenText; + var response = await SendAsync(server, "http://example.com/oauth", newSharedKeyToken, date); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("SharedKey error=\"invalid_token\", error_description=\"Unable to resolve signing keys\"", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Fact] + public async Task InvalidTokenReceived_NotBase64Key() + { + var date = DateTimeOffset.UtcNow.ToString("r"); + (var tokenText, var key) = CreateStandardTokenAndKey(date, "/oauth"); + + var server = CreateServer(o => + { + o.ValidationParameters = new SharedKeyTokenValidationParameters + { + KnownFixedKeys = Array.Empty(), + KeysResolver = (ctx) => Task.FromResult((IEnumerable)new[] { "not-base64", "BEahPY/aD0KqvZdLuNQJBw==" }) + }; + }); + + var newSharedKeyToken = "SharedKey " + tokenText; + var response = await SendAsync(server, "http://example.com/oauth", newSharedKeyToken, date); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("SharedKey error=\"invalid_token\", error_description=\"Invalid signing keys\"", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Theory] + [InlineData(typeof(SharedKeyInvalidDateException), "The supplied date is invalid; Supplied: '(null)'")] + [InlineData(typeof(SharedKeyTimeWindowExpiredException), "The supplied date is invalid; NotOlderThan: '01/01/0001 00:00:00 +00:00', Supplied: '01/01/0001 00:00:00 +00:00'")] + [InlineData(typeof(SharedKeyInvalidSignatureException), "The signature is invalid")] + [InlineData(typeof(SharedKeyNoDateException), "Date header must be supplied in any of these headers ()")] + [InlineData(typeof(SharedKeyNoKeysException), "Unable to resolve signing keys")] + [InlineData(typeof(SharedKeyInvalidSigningKeysException), "Invalid signing keys")] + public async Task ExceptionReportedInHeaderForAuthenticationFailures(Type errorType, string message) + { + var server = CreateServer(options => + { + options.TokenValidators.Clear(); + options.TokenValidators.Add(new InvalidTokenValidator(errorType)); + }); + + var response = await SendAsync(server, "http://example.com/oauth", "SharedKey someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal($"SharedKey error=\"invalid_token\", error_description=\"{message}\"", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Theory] + [InlineData(typeof(SharedKeyInvalidDateException), "The supplied date is invalid; Supplied: '01/15/2045 00:00:00 +00:00'")] + [InlineData(typeof(SharedKeyTimeWindowExpiredException), "The supplied date is invalid; NotOlderThan: '01/15/2001 00:00:00 +00:00', Supplied: '02/20/2000 00:00:00 +00:00'")] + [InlineData(typeof(SharedKeyNoDateException), "Date header must be supplied in any of these headers (x-ts-date, x-ms-date)")] + public async Task ExceptionReportedInHeaderWithDetailsForAuthenticationFailures(Type errorType, string message) + { + var server = CreateServer(options => + { + options.TokenValidators.Clear(); + options.TokenValidators.Add(new DetailedInvalidTokenValidator(errorType)); + }); + + var response = await SendAsync(server, "http://example.com/oauth", "SharedKey someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal($"SharedKey error=\"invalid_token\", error_description=\"{message}\"", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Theory] + [InlineData(typeof(ArgumentException))] + public async Task ExceptionNotReportedInHeaderForOtherFailures(Type errorType) + { + var server = CreateServer(options => + { + options.TokenValidators.Clear(); + options.TokenValidators.Add(new InvalidTokenValidator(errorType)); + }); + + var response = await SendAsync(server, "http://example.com/oauth", "SharedKey someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("SharedKey error=\"invalid_token\"", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Fact] + public async Task ExceptionsReportedInHeaderForMultipleAuthenticationFailures() + { + var server = CreateServer(options => + { + options.TokenValidators.Clear(); + options.TokenValidators.Add(new InvalidTokenValidator(typeof(SharedKeyNoKeysException))); + options.TokenValidators.Add(new InvalidTokenValidator(typeof(SharedKeyInvalidSignatureException))); + }); + + var response = await SendAsync(server, "http://example.com/oauth", "SharedKey someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("SharedKey error=\"invalid_token\", error_description=\"Unable to resolve signing keys; The signature is invalid\"", + response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Theory] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", null, null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, null, "custom_uri")] + public async Task ExceptionsReportedInHeaderExposesUserDefinedError(string error, string description, string uri) + { + var server = CreateServer(options => + { + options.Events = new SharedKeyEvents + { + OnChallenge = context => + { + context.Error = error; + context.ErrorDescription = description; + context.ErrorUri = uri; + + return Task.FromResult(0); + } + }; + }); + + var response = await SendAsync(server, "http://example.com/oauth", "SharedKey someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("", response.ResponseText); + + var builder = new StringBuilder(SharedKeyDefaults.AuthenticationScheme); + + if (!string.IsNullOrEmpty(error)) + { + builder.Append(" error=\""); + builder.Append(error); + builder.Append('"'); + } + if (!string.IsNullOrEmpty(description)) + { + if (!string.IsNullOrEmpty(error)) + { + builder.Append(','); + } + + builder.Append(" error_description=\""); + builder.Append(description); + builder.Append('\"'); + } + if (!string.IsNullOrEmpty(uri)) + { + if (!string.IsNullOrEmpty(error) || + !string.IsNullOrEmpty(description)) + { + builder.Append(','); + } + + builder.Append(" error_uri=\""); + builder.Append(uri); + builder.Append('\"'); + } + + Assert.Equal(builder.ToString(), response.Response.Headers.WwwAuthenticate.First().ToString()); + } + + [Fact] + public async Task ExceptionNotReportedInHeaderWhenIncludeErrorDetailsIsFalse() + { + var server = CreateServer(o => + { + o.IncludeErrorDetails = false; + }); + + var response = await SendAsync(server, "http://example.com/oauth", "SharedKey someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("SharedKey", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Fact] + public async Task ExceptionNotReportedInHeaderWhenTokenWasMissing() + { + var server = CreateServer(); + + var response = await SendAsync(server, "http://example.com/oauth"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("SharedKey", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Fact] + public async Task CustomTokenValidated() + { + var server = CreateServer(options => + { + options.Events = new SharedKeyEvents() + { + OnTokenValidated = context => + { + // Retrieve the NameIdentifier claim from the identity + // returned by the custom security token validator. + var identity = (ClaimsIdentity)context.Principal.Identity; + var identifier = identity.FindFirst(ClaimTypes.NameIdentifier); + + Assert.Equal("Bob le Tout Puissant", identifier.Value); + + // Remove the existing NameIdentifier claim and replace it + // with a new one containing a different value. + identity.RemoveClaim(identifier); + // Make sure to use a different name identifier + // than the one defined by BlobTokenValidator. + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "Bob le Magnifique")); + + return Task.FromResult(null); + } + }; + options.TokenValidators.Clear(); + options.TokenValidators.Add(new BlobTokenValidator(SharedKeyDefaults.AuthenticationScheme)); + }); + + var response = await SendAsync(server, "http://example.com/oauth", "SharedKey someblob"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal("Bob le Magnifique", response.ResponseText); + } + + [Fact] + public async Task RetrievingTokenFromAlternateLocation() + { + var server = CreateServer(options => + { + options.Events = new SharedKeyEvents() + { + OnMessageReceived = context => + { + context.Token = "CustomToken"; + return Task.FromResult(null); + } + }; + options.TokenValidators.Clear(); + options.TokenValidators.Add(new BlobTokenValidator("SK", token => + { + Assert.Equal("CustomToken", token); + })); + }); + + var response = await SendAsync(server, "http://example.com/oauth", "SharedKey Token"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal("Bob le Tout Puissant", response.ResponseText); + } + + [Fact] + public async Task EventOnMessageReceivedSkip_NoMoreEventsExecuted() + { + var server = CreateServer(options => + { + options.Events = new SharedKeyEvents() + { + OnMessageReceived = context => + { + context.NoResult(); + return Task.FromResult(0); + }, + OnTokenValidated = context => + { + throw new NotImplementedException(); + }, + OnAuthenticationFailed = context => + { + throw new NotImplementedException(context.Exception.ToString()); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + }); + + var response = await SendAsync(server, "http://example.com/checkforerrors", "SharedKey Token"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal(string.Empty, response.ResponseText); + } + + [Fact] + public async Task EventOnMessageReceivedReject_NoMoreEventsExecuted() + { + var server = CreateServer(options => + { + options.Events = new SharedKeyEvents() + { + OnMessageReceived = context => + { + context.Fail("Authentication was aborted from user code."); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }, + OnTokenValidated = context => + { + throw new NotImplementedException(); + }, + OnAuthenticationFailed = context => + { + throw new NotImplementedException(context.Exception.ToString()); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + }); + + var exception = await Assert.ThrowsAsync(delegate + { + return SendAsync(server, "http://example.com/checkforerrors", "SharedKey Token"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + } + + [Fact] + public async Task EventOnTokenValidatedSkip_NoMoreEventsExecuted() + { + var server = CreateServer(options => + { + options.Events = new SharedKeyEvents() + { + OnTokenValidated = context => + { + context.NoResult(); + return Task.FromResult(0); + }, + OnAuthenticationFailed = context => + { + throw new NotImplementedException(context.Exception.ToString()); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + options.TokenValidators.Clear(); + options.TokenValidators.Add(new BlobTokenValidator("SK")); + }); + + var response = await SendAsync(server, "http://example.com/checkforerrors", "SharedKey Token"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal(string.Empty, response.ResponseText); + } + + [Fact] + public async Task EventOnTokenValidatedReject_NoMoreEventsExecuted() + { + var server = CreateServer(options => + { + options.Events = new SharedKeyEvents() + { + OnTokenValidated = context => + { + context.Fail("Authentication was aborted from user code."); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }, + OnAuthenticationFailed = context => + { + throw new NotImplementedException(context.Exception.ToString()); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + options.TokenValidators.Clear(); + options.TokenValidators.Add(new BlobTokenValidator("SK")); + }); + + var exception = await Assert.ThrowsAsync(delegate + { + return SendAsync(server, "http://example.com/checkforerrors", "SharedKey Token"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + } + + [Fact] + public async Task EventOnAuthenticationFailedSkip_NoMoreEventsExecuted() + { + var server = CreateServer(options => + { + options.Events = new SharedKeyEvents() + { + OnTokenValidated = context => + { + throw new Exception("Test Exception"); + }, + OnAuthenticationFailed = context => + { + context.NoResult(); + return Task.FromResult(0); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + options.TokenValidators.Clear(); + options.TokenValidators.Add(new BlobTokenValidator("SK")); + }); + + var response = await SendAsync(server, "http://example.com/checkforerrors", "SharedKey Token"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal(string.Empty, response.ResponseText); + } + + [Fact] + public async Task EventOnAuthenticationFailedReject_NoMoreEventsExecuted() + { + var server = CreateServer(options => + { + options.Events = new SharedKeyEvents() + { + OnTokenValidated = context => + { + throw new Exception("Test Exception"); + }, + OnAuthenticationFailed = context => + { + context.Fail("Authentication was aborted from user code."); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + options.TokenValidators.Clear(); + options.TokenValidators.Add(new BlobTokenValidator("SK")); + }); + + var exception = await Assert.ThrowsAsync(delegate + { + return SendAsync(server, "http://example.com/checkforerrors", "SharedKey Token"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + } + + [Fact] + public async Task EventOnChallengeSkip_ResponseNotModified() + { + var server = CreateServer(o => + { + o.Events = new SharedKeyEvents() + { + OnChallenge = context => + { + context.HandleResponse(); + return Task.FromResult(0); + }, + }; + }); + + var response = await SendAsync(server, "http://example.com/unauthorized", "SharedKey Token"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Empty(response.Response.Headers.WwwAuthenticate); + Assert.Equal(string.Empty, response.ResponseText); + } + + [Fact] + public async Task EventOnForbidden_ResponseNotModified() + { + var date = DateTimeOffset.UtcNow.ToString("r"); + (var tokenText, var key) = CreateStandardTokenAndKey(date, "/forbidden"); + + var server = CreateServer(o => + { + o.ValidationParameters = new SharedKeyTokenValidationParameters + { + KnownFixedKeys = Array.Empty(), + KeysResolver = (ctx) => Task.FromResult((IEnumerable)new[] { key }) + }; + }); + var newSharedKeyToken = "SharedKey " + tokenText; + var response = await SendAsync(server, "http://example.com/forbidden", newSharedKeyToken, date); + Assert.Equal(HttpStatusCode.Forbidden, response.Response.StatusCode); + } + + [Fact] + public async Task EventOnForbiddenSkip_ResponseNotModified() + { + var date = DateTimeOffset.UtcNow.ToString("r"); + (var tokenText, var key) = CreateStandardTokenAndKey(date, "/forbidden"); + var server = CreateServer(o => + { + o.ValidationParameters = new SharedKeyTokenValidationParameters + { + KnownFixedKeys = Array.Empty(), + KeysResolver = (ctx) => Task.FromResult((IEnumerable)new[] { key }) + }; + o.Events = new SharedKeyEvents() + { + OnForbidden = context => + { + return Task.FromResult(0); + } + }; + }); + var newSharedKeyToken = "SharedKey " + tokenText; + var response = await SendAsync(server, "http://example.com/forbidden", newSharedKeyToken, date); + Assert.Equal(HttpStatusCode.Forbidden, response.Response.StatusCode); + } + + [Fact] + public async Task EventOnForbidden_ResponseModified() + { + var date = DateTimeOffset.UtcNow.ToString("r"); + (var tokenText, var key) = CreateStandardTokenAndKey(date, "/forbidden"); + var server = CreateServer(o => + { + o.ValidationParameters = new SharedKeyTokenValidationParameters + { + KnownFixedKeys = Array.Empty(), + KeysResolver = (ctx) => Task.FromResult((IEnumerable)new[] { key }) + }; + o.Events = new SharedKeyEvents() + { + OnForbidden = context => + { + context.Response.StatusCode = 418; + return context.Response.WriteAsync("You Shall Not Pass"); + } + }; + }); + var newSharedKeyToken = "SharedKey " + tokenText; + var response = await SendAsync(server, "http://example.com/forbidden", newSharedKeyToken, date); + Assert.Equal(418, (int)response.Response.StatusCode); + Assert.Equal("You Shall Not Pass", await response.Response.Content.ReadAsStringAsync()); + } + + class InvalidTokenValidator : ISharedKeyTokenValidator + { + public InvalidTokenValidator() + { + ExceptionType = typeof(SharedKeyTokenException); + } + + public InvalidTokenValidator(Type exceptionType) + { + ExceptionType = exceptionType; + } + + public Type ExceptionType { get; set; } + + public bool CanReadToken(string securityToken) => true; + + public Task<(ClaimsPrincipal, SharedKeyValidatedToken)> ValidateTokenAsync(string securityToken, + HttpContext httpContext, + SharedKeyOptions options) + { + var constructor = ExceptionType.GetTypeInfo().GetConstructor(new[] { typeof(string) }); + var exception = (Exception)constructor.Invoke(new[] { ExceptionType.Name }); + throw exception; + } + } + + class DetailedInvalidTokenValidator : ISharedKeyTokenValidator + { + public DetailedInvalidTokenValidator() + { + ExceptionType = typeof(SharedKeyTokenException); + } + + public DetailedInvalidTokenValidator(Type exceptionType) + { + ExceptionType = exceptionType; + } + + public Type ExceptionType { get; set; } + + public bool CanReadToken(string securityToken) => true; + + public Task<(ClaimsPrincipal, SharedKeyValidatedToken)> ValidateTokenAsync(string securityToken, + HttpContext httpContext, + SharedKeyOptions options) + { + if (ExceptionType == typeof(SharedKeyNoDateException)) + { + throw SharedKeyNoDateException.Create(new List { "x-ts-date", "x-ms-date" }); + } + if (ExceptionType == typeof(SharedKeyTimeWindowExpiredException)) + { + throw SharedKeyTimeWindowExpiredException.Create(new DateTimeOffset(2000, 2, 20, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2001, 1, 15, 0, 0, 0, TimeSpan.Zero)); + } + if (ExceptionType == typeof(SharedKeyInvalidDateException)) + { + var value = new DateTimeOffset(2045, 1, 15, 0, 0, 0, TimeSpan.Zero).ToString(CultureInfo.InvariantCulture); + throw SharedKeyInvalidDateException.Create(value); + } + else + { + throw new NotImplementedException(ExceptionType.Name); + } + } + } + + class BlobTokenValidator : ISharedKeyTokenValidator + { + private readonly Action _tokenValidator; + + public BlobTokenValidator(string authenticationScheme) + { + AuthenticationScheme = authenticationScheme; + + } + public BlobTokenValidator(string authenticationScheme, Action tokenValidator) + { + AuthenticationScheme = authenticationScheme; + _tokenValidator = tokenValidator; + } + + public string AuthenticationScheme { get; } + + public bool CanReadToken(string securityToken) => true; + + public Task<(ClaimsPrincipal, SharedKeyValidatedToken)> ValidateTokenAsync(string securityToken, + HttpContext httpContext, + SharedKeyOptions options) + { + SharedKeyValidatedToken validatedToken = null; + _tokenValidator?.Invoke(securityToken); + + var claims = new[] + { + // Make sure to use a different name identifier + // than the one defined by CustomTokenValidated. + new Claim(ClaimTypes.NameIdentifier, "Bob le Tout Puissant"), + new Claim(ClaimTypes.Email, "bob@contoso.com"), + new Claim(ClaimsIdentity.DefaultNameClaimType, "bob"), + }; + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthenticationScheme)); + return Task.FromResult((principal, validatedToken)); + } + } + + private static TestServer CreateServer(Action options = null, Func, Task> handlerBeforeAuth = null) + { + var builder = new WebHostBuilder() + .Configure(app => + { + if (handlerBeforeAuth != null) + { + app.Use(handlerBeforeAuth); + } + + app.UseAuthentication(); + app.Use(async (context, next) => + { + if (context.Request.Path == new PathString("/checkforerrors")) + { + var result = await context.AuthenticateAsync(SharedKeyDefaults.AuthenticationScheme); // this used to be "Automatic" + if (result.Failure != null) + { + throw new Exception("Failed to authenticate", result.Failure); + } + return; + } + else if (context.Request.Path == new PathString("/oauth")) + { + if (context.User == null || + context.User.Identity == null || + !context.User.Identity.IsAuthenticated) + { + context.Response.StatusCode = 401; + // REVIEW: no more automatic challenge + await context.ChallengeAsync(SharedKeyDefaults.AuthenticationScheme); + return; + } + + var identifier = context.User.FindFirst(ClaimTypes.NameIdentifier); + if (identifier == null) + { + context.Response.StatusCode = 500; + return; + } + + await context.Response.WriteAsync(identifier.Value); + } + else if (context.Request.Path == new PathString("/token")) + { + var token = await context.GetTokenAsync("access_token"); + await context.Response.WriteAsync(token); + } + else if (context.Request.Path == new PathString("/unauthorized")) + { + // Simulate Authorization failure + var result = await context.AuthenticateAsync(SharedKeyDefaults.AuthenticationScheme); + await context.ChallengeAsync(SharedKeyDefaults.AuthenticationScheme); + } + else if (context.Request.Path == new PathString("/forbidden")) + { + // Simulate Forbidden + await context.ForbidAsync(SharedKeyDefaults.AuthenticationScheme); + } + else if (context.Request.Path == new PathString("/signIn")) + { + await Assert.ThrowsAsync(() => context.SignInAsync(SharedKeyDefaults.AuthenticationScheme, new ClaimsPrincipal())); + } + else if (context.Request.Path == new PathString("/signOut")) + { + await Assert.ThrowsAsync(() => context.SignOutAsync(SharedKeyDefaults.AuthenticationScheme)); + } + else + { + await next(); + } + }); + }) + .ConfigureServices(services => services.AddAuthentication(SharedKeyDefaults.AuthenticationScheme).AddSharedKey(options)); + + return new TestServer(builder); + } + + // TODO: see if we can share the TestExtensions SendAsync method (only diff is auth header) + private static async Task SendAsync(TestServer server, string uri, string authorizationHeader = null, string dateHeader = null) + { + var request = new HttpRequestMessage(HttpMethod.Get, uri); + if (!string.IsNullOrEmpty(authorizationHeader)) + { + request.Headers.Add("Authorization", authorizationHeader); + } + if (!string.IsNullOrEmpty(dateHeader)) + { + request.Headers.Add("x-ts-date", dateHeader); + } + + var transaction = new Transaction + { + Request = request, + Response = await server.CreateClient().SendAsync(request), + }; + + transaction.ResponseText = await transaction.Response.Content.ReadAsStringAsync(); + + if (transaction.Response.Content != null && + transaction.Response.Content.Headers.ContentType != null && + transaction.Response.Content.Headers.ContentType.MediaType == "text/xml") + { + transaction.ResponseElement = XElement.Parse(transaction.ResponseText); + } + + return transaction; + } + + private static (string tokenText, string key) CreateStandardTokenAndKey(string dateHeaderValue, + string resource, + string method = "GET", + int contentLength = 0, + string contentType = null) + { + var sharedKeyBytes = Guid.NewGuid().ToByteArray(); + var key = Convert.ToBase64String(sharedKeyBytes); + + // collect items + dateHeaderValue ??= string.Empty; + + var stringToHash = string.Join("\n", method, contentLength, contentType, $"x-ts-date:{dateHeaderValue}", resource); + var bytesToHash = Encoding.ASCII.GetBytes(stringToHash); + using var sha256 = new System.Security.Cryptography.HMACSHA256(sharedKeyBytes); + var calculatedHash = sha256.ComputeHash(bytesToHash); + var signature = Convert.ToBase64String(calculatedHash); + return (tokenText: signature, key); + } +} diff --git a/tests/Tingle.AspNetCore.Authentication.Tests/TestAuthHandler.cs b/tests/Tingle.AspNetCore.Authentication.Tests/TestAuthHandler.cs new file mode 100644 index 0000000..eeb35d8 --- /dev/null +++ b/tests/Tingle.AspNetCore.Authentication.Tests/TestAuthHandler.cs @@ -0,0 +1,114 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +#nullable disable // aligned with tests for inbuilt authentication + +namespace Tingle.AspNetCore.Authentication.Tests; + +public class TestAuthHandler : AuthenticationHandler, IAuthenticationSignInHandler +{ + public TestAuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) + { } + + public int SignInCount { get; set; } + public int SignOutCount { get; set; } + public int ForbidCount { get; set; } + public int ChallengeCount { get; set; } + public int AuthenticateCount { get; set; } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + ChallengeCount++; + return Task.CompletedTask; + } + + protected override Task HandleForbiddenAsync(AuthenticationProperties properties) + { + ForbidCount++; + return Task.CompletedTask; + } + + protected override Task HandleAuthenticateAsync() + { + AuthenticateCount++; + var principal = new ClaimsPrincipal(); + var id = new ClaimsIdentity(); + id.AddClaim(new Claim(ClaimTypes.NameIdentifier, Scheme.Name, ClaimValueTypes.String, Scheme.Name)); + principal.AddIdentity(id); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, new AuthenticationProperties(), Scheme.Name))); + } + + public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties properties) + { + SignInCount++; + return Task.CompletedTask; + } + + public Task SignOutAsync(AuthenticationProperties properties) + { + SignOutCount++; + return Task.CompletedTask; + } +} + +public class TestHandler : IAuthenticationSignInHandler +{ + public AuthenticationScheme Scheme { get; set; } + public int SignInCount { get; set; } + public int SignOutCount { get; set; } + public int ForbidCount { get; set; } + public int ChallengeCount { get; set; } + public int AuthenticateCount { get; set; } + + public Task AuthenticateAsync() + { + AuthenticateCount++; + var principal = new ClaimsPrincipal(); + var id = new ClaimsIdentity(); + id.AddClaim(new Claim(ClaimTypes.NameIdentifier, Scheme.Name, ClaimValueTypes.String, Scheme.Name)); + principal.AddIdentity(id); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, new AuthenticationProperties(), Scheme.Name))); + } + + public Task ChallengeAsync(AuthenticationProperties properties) + { + ChallengeCount++; + return Task.CompletedTask; + } + + public Task ForbidAsync(AuthenticationProperties properties) + { + ForbidCount++; + return Task.CompletedTask; + } + + public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context) + { + Scheme = scheme; + return Task.CompletedTask; + } + + public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties properties) + { + SignInCount++; + return Task.CompletedTask; + } + + public Task SignOutAsync(AuthenticationProperties properties) + { + SignOutCount++; + return Task.CompletedTask; + } +} + +public class TestHandler2 : TestHandler +{ +} + +public class TestHandler3 : TestHandler +{ +} diff --git a/tests/Tingle.AspNetCore.Authentication.Tests/TestClock.cs b/tests/Tingle.AspNetCore.Authentication.Tests/TestClock.cs new file mode 100644 index 0000000..6eff40f --- /dev/null +++ b/tests/Tingle.AspNetCore.Authentication.Tests/TestClock.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Authentication; + +namespace Tingle.AspNetCore.Authentication.Tests; + +public class TestClock : ISystemClock +{ + public TestClock() + { + UtcNow = new DateTimeOffset(2013, 6, 11, 12, 34, 56, 789, TimeSpan.Zero); + } + + public DateTimeOffset UtcNow { get; set; } + + public void Add(TimeSpan timeSpan) + { + UtcNow = UtcNow + timeSpan; + } +} diff --git a/tests/Tingle.AspNetCore.Authentication.Tests/TestExtensions.cs b/tests/Tingle.AspNetCore.Authentication.Tests/TestExtensions.cs new file mode 100644 index 0000000..21695f1 --- /dev/null +++ b/tests/Tingle.AspNetCore.Authentication.Tests/TestExtensions.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using System.Security.Claims; +using System.Text; +using System.Xml.Linq; + +#nullable disable // aligned with tests for inbuilt authentication + +namespace Tingle.AspNetCore.Authentication.Tests; + +public static class TestExtensions +{ + public const string CookieAuthenticationScheme = "External"; + + public static async Task SendAsync(this TestServer server, string uri, string cookieHeader = null) + { + var request = new HttpRequestMessage(HttpMethod.Get, uri); + if (!string.IsNullOrEmpty(cookieHeader)) + { + request.Headers.Add("Cookie", cookieHeader); + } + var transaction = new Transaction + { + Request = request, + Response = await server.CreateClient().SendAsync(request), + }; + if (transaction.Response.Headers.Contains("Set-Cookie")) + { + transaction.SetCookie = transaction.Response.Headers.GetValues("Set-Cookie").ToList(); + } + transaction.ResponseText = await transaction.Response.Content.ReadAsStringAsync(); + + if (transaction.Response.Content != null && + transaction.Response.Content.Headers.ContentType != null && + transaction.Response.Content.Headers.ContentType.MediaType == "text/xml") + { + transaction.ResponseElement = XElement.Parse(transaction.ResponseText); + } + return transaction; + } + + public static Task DescribeAsync(this HttpResponse res, ClaimsPrincipal principal) + { + res.StatusCode = 200; + res.ContentType = "text/xml"; + var xml = new XElement("xml"); + if (principal != null) + { + foreach (var identity in principal.Identities) + { + xml.Add(identity.Claims.Select(claim => + new XElement("claim", new XAttribute("type", claim.Type), + new XAttribute("value", claim.Value), + new XAttribute("issuer", claim.Issuer)))); + } + } + var xmlBytes = Encoding.UTF8.GetBytes(xml.ToString()); + return res.Body.WriteAsync(xmlBytes, 0, xmlBytes.Length); + } + + public static Task DescribeAsync(this HttpResponse res, IEnumerable tokens) + { + res.StatusCode = 200; + res.ContentType = "text/xml"; + var xml = new XElement("xml"); + if (tokens != null) + { + foreach (var token in tokens) + { + xml.Add(new XElement("token", new XAttribute("name", token.Name), + new XAttribute("value", token.Value))); + } + } + var xmlBytes = Encoding.UTF8.GetBytes(xml.ToString()); + return res.Body.WriteAsync(xmlBytes, 0, xmlBytes.Length); + } + +} diff --git a/tests/Tingle.AspNetCore.Authentication.Tests/Tingle.AspNetCore.Authentication.Tests.csproj b/tests/Tingle.AspNetCore.Authentication.Tests/Tingle.AspNetCore.Authentication.Tests.csproj new file mode 100644 index 0000000..d5b1d7b --- /dev/null +++ b/tests/Tingle.AspNetCore.Authentication.Tests/Tingle.AspNetCore.Authentication.Tests.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/Tingle.AspNetCore.Authentication.Tests/Transaction.cs b/tests/Tingle.AspNetCore.Authentication.Tests/Transaction.cs new file mode 100644 index 0000000..f0da19a --- /dev/null +++ b/tests/Tingle.AspNetCore.Authentication.Tests/Transaction.cs @@ -0,0 +1,57 @@ +using System.Xml.Linq; + +#nullable disable // aligned with tests for inbuilt authentication + +namespace Tingle.AspNetCore.Authentication.Tests; + +public class Transaction +{ + public HttpRequestMessage Request { get; set; } + public HttpResponseMessage Response { get; set; } + + public IList SetCookie { get; set; } + + public string ResponseText { get; set; } + public XElement ResponseElement { get; set; } + + public string AuthenticationCookieValue + { + get + { + if (SetCookie != null && SetCookie.Count > 0) + { + var authCookie = SetCookie.SingleOrDefault(c => c.Contains(".AspNetCore." + TestExtensions.CookieAuthenticationScheme + "=")); + if (authCookie != null) + { + return authCookie.Substring(0, authCookie.IndexOf(';')); + } + } + + return null; + } + } + + public string FindClaimValue(string claimType, string issuer = null) + { + var claim = ResponseElement.Elements("claim") + .SingleOrDefault(elt => elt.Attribute("type").Value == claimType && + (issuer == null || elt.Attribute("issuer").Value == issuer)); + if (claim == null) + { + return null; + } + return claim.Attribute("value").Value; + } + + public string FindTokenValue(string name) + { + var claim = ResponseElement.Elements("token") + .SingleOrDefault(elt => elt.Attribute("name").Value == name); + if (claim == null) + { + return null; + } + return claim.Attribute("value").Value; + } + +}