From 19bb6fdf86194003a4dd3ad6027c4eaf79d252f7 Mon Sep 17 00:00:00 2001 From: Ivan Josipovic <9521987+IvanJosipovic@users.noreply.github.com> Date: Fri, 30 Jun 2023 21:47:02 -0700 Subject: [PATCH] feat: Update (#13) * feat: add robots.txt * feat: userinfo endpoint * feat: signout endpoint * feat: unauthorized returns failed claim name * feat: configurable cookie auth scopes * feat: map all cookie claims * feat: configurable valid audiences for JWT validation --- .github/renovate.json | 21 +-- README.md | 2 +- charts/oidc-guard/Chart.yaml | 2 +- charts/oidc-guard/values.yaml | 13 +- src/oidc-guard/Controllers/AuthController.cs | 118 +++++++------ src/oidc-guard/Program.cs | 15 +- src/oidc-guard/Settings.cs | 10 +- .../AllowedRedirectDomainTests.cs | 6 +- tests/oidc-guard-tests/AuthTests.cs | 165 ++++-------------- .../Infra/AuthTestsHelpers.cs | 64 +++++++ .../{ => Infra}/FakeJwtIssuer.cs | 6 +- .../{ => Infra}/MyWebApplicationFactory.cs | 2 +- .../{ => Infra}/SigninMiddleware.cs | 6 +- .../{ => Infra}/SigninStartupFilter.cs | 2 +- .../TestServerDocumentRetriever.cs | 2 +- tests/oidc-guard-tests/SignInOutTests.cs | 63 +++++++ tests/oidc-guard-tests/SkipAuthPreflight.cs | 65 +++++++ tests/oidc-guard-tests/Usings.cs | 3 - 18 files changed, 349 insertions(+), 216 deletions(-) create mode 100644 tests/oidc-guard-tests/Infra/AuthTestsHelpers.cs rename tests/oidc-guard-tests/{ => Infra}/FakeJwtIssuer.cs (82%) rename tests/oidc-guard-tests/{ => Infra}/MyWebApplicationFactory.cs (95%) rename tests/oidc-guard-tests/{ => Infra}/SigninMiddleware.cs (91%) rename tests/oidc-guard-tests/{ => Infra}/SigninStartupFilter.cs (91%) rename tests/oidc-guard-tests/{ => Infra}/TestServerDocumentRetriever.cs (98%) create mode 100644 tests/oidc-guard-tests/SignInOutTests.cs create mode 100644 tests/oidc-guard-tests/SkipAuthPreflight.cs delete mode 100644 tests/oidc-guard-tests/Usings.cs diff --git a/.github/renovate.json b/.github/renovate.json index 5741fd2..953b0e0 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -20,6 +20,7 @@ "matchCurrentVersion": "!/^0/", "automerge": true, "extends": [":semanticCommitTypeAll(fix)"], + "excludePackageNames": ["Microsoft.VisualStudio.Azure.Containers.Tools.Targets"], "matchPaths": [ "src/**", "global.json" @@ -29,22 +30,18 @@ "matchUpdateTypes": ["minor", "patch", "digest"], "matchCurrentVersion": "!/^0/", "automerge": true, + "matchPackageNames": ["Microsoft.VisualStudio.Azure.Containers.Tools.Targets"], "matchPaths": [ - "tests/**", - "benchmarks/**" + "src/**" ] }, { - "groupName": "IdentityModel", - "separateMajorMinor": true, - "groupSlug": "identitymodel-libs", - "packageRules": [ - { - "matchPackagePatterns": [ - "System.IdentityModel.*", - "Microsoft.IdentityModel.*" - ] - } + "matchUpdateTypes": ["minor", "patch", "digest"], + "matchCurrentVersion": "!/^0/", + "automerge": true, + "matchPaths": [ + "tests/**", + "benchmarks/**" ] } ] diff --git a/README.md b/README.md index ec64e52..657ec0a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![GitHub](https://img.shields.io/github/stars/ivanjosipovic/oidc-guard?style=social)](https://github.com/IvanJosipovic/oidc-guard) [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/oidc-guard)](https://artifacthub.io/packages/helm/oidc-guard/oidc-guard) -OpenID Connect (OIDC) Proxy Server for securing Kubernetes Ingress +OpenID Connect (OIDC) & OAuth 2 Proxy Server for securing Kubernetes Ingress ## What is this? diff --git a/charts/oidc-guard/Chart.yaml b/charts/oidc-guard/Chart.yaml index 3992f2e..cc2a302 100644 --- a/charts/oidc-guard/Chart.yaml +++ b/charts/oidc-guard/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: oidc-guard -description: OpenID Connect (OIDC) Proxy Server for securing Kubernetes Ingress +description: OpenID Connect (OIDC) & OAuth 2 Proxy Server for securing Kubernetes Ingress # A chart can be either an 'application' or a 'library' chart. # diff --git a/charts/oidc-guard/values.yaml b/charts/oidc-guard/values.yaml index a1cbe43..2c25468 100644 --- a/charts/oidc-guard/values.yaml +++ b/charts/oidc-guard/values.yaml @@ -39,17 +39,26 @@ settings: # Client Secret clientSecret: "" + # Scopes to request + scopes: + - openid + - profile + # Control if the access and refresh tokens should be stored in the cookie, # disable to reduce the size of the authentication cookie. # You may have to set 'large-client-header-buffers: 4 16k' in ingress-nginx saveTokensInCookie: false - # Control if the audience will be validated during token validation. + # Control if the audience will be validated during JWT token validation. # Validation of the audience, mitigates forwarding attacks. For example, a site that receives a token, could not replay it to another site. # This value can be validated at the Ingress level using /auth?aud=00000000-0000-0000-0000-000000000000 validateAudience: false - # Control if the issuer will be validated during token validation. + # Set valid audiences for JWT validation + validAudiences: [] + # - 11111111-1111-1111-1111-111111111111 + + # Control if the issuer will be validated during JWT token validation. # Validation of the issuer mitigates forwarding attacks that can occur when an # Identity Provider represents multiple tenants and signs tokens with the same keys. # It is possible that a token issued for the same audience could be from a different tenant. diff --git a/src/oidc-guard/Controllers/AuthController.cs b/src/oidc-guard/Controllers/AuthController.cs index cb76764..fc891a9 100644 --- a/src/oidc-guard/Controllers/AuthController.cs +++ b/src/oidc-guard/Controllers/AuthController.cs @@ -8,6 +8,7 @@ namespace oidc_guard.Controllers; [ApiController] [Route("")] +[Authorize] public class AuthController : ControllerBase { private readonly ILogger _logger; @@ -27,7 +28,7 @@ public AuthController(ILogger logger, Settings settings) [HttpGet("auth")] [AllowAnonymous] - public ActionResult Auth() + public IActionResult Auth() { if (settings.SkipAuthPreflight && HttpContext.Request.Headers[CustomHeaderNames.OriginalMethod].FirstOrDefault() == "OPTIONS" && @@ -46,53 +47,55 @@ public ActionResult Auth() } // Validate based on rules - - foreach (var item in Request.Query) + if (Request.QueryString.HasValue) { - if (item.Key.Equals("inject-claim", StringComparison.InvariantCultureIgnoreCase)) + foreach (var item in Request.Query) { - foreach (var value in item.Value) + if (item.Key.Equals("inject-claim", StringComparison.InvariantCultureIgnoreCase)) { - if (string.IsNullOrEmpty(value)) - { - continue; - } - - string claimName; - string headerName; - - if (value.Contains(',')) + foreach (var value in item.Value) { - claimName = value.Split(',')[0]; - headerName = value.Split(',')[1]; - } - else - { - claimName = value; - headerName = value; - } - - var claims = HttpContext.User.Claims.Where(x => x.Type == claimName || x.Properties.Any(y => y.Value == claimName)).ToArray(); - - if (claims == null || claims.Length == 0) - { - continue; - } - - if (claims.Length == 1) - { - Response.Headers.Add(headerName, claims[0].Value); - } - else - { - Response.Headers.Add(headerName, new StringValues(claims.Select(x => x.Value).ToArray())); + if (string.IsNullOrEmpty(value)) + { + continue; + } + + string claimName; + string headerName; + + if (value.Contains(',')) + { + claimName = value.Split(',')[0]; + headerName = value.Split(',')[1]; + } + else + { + claimName = value; + headerName = value; + } + + var claims = HttpContext.User.Claims.Where(x => x.Type == claimName).ToArray(); + + if (claims == null || claims.Length == 0) + { + continue; + } + + if (claims.Length == 1) + { + Response.Headers.Add(headerName, claims[0].Value); + } + else + { + Response.Headers.Add(headerName, new StringValues(claims.Select(x => x.Value).ToArray())); + } } } - } - else if (!HttpContext.User.Claims.Any(x => (x.Type == item.Key || x.Properties.Any(y => y.Value == item.Key)) && item.Value.Any(y => y?.Equals(x.Value) == true))) - { - UnauthorizedGauge.Inc(); - return Unauthorized(); + else if (!HttpContext.User.Claims.Any(x => x.Type == item.Key && item.Value.Contains(x.Value))) + { + UnauthorizedGauge.Inc(); + return Unauthorized($"Claim {item.Key} does not match!"); + } } } @@ -102,19 +105,15 @@ public ActionResult Auth() [HttpGet("signin")] [AllowAnonymous] - public ActionResult Signin([FromQuery] Uri rd) + public IActionResult SignIn([FromQuery] Uri rd) { if (settings.AllowedRedirectDomains?.Length > 0 && rd.IsAbsoluteUri) { var found = false; foreach (var allowedDomain in settings.AllowedRedirectDomains) { - if (allowedDomain[0] == '.' && rd.DnsSafeHost.EndsWith(allowedDomain, StringComparison.InvariantCultureIgnoreCase)) - { - found = true; - break; - } - else if (rd.DnsSafeHost.Equals(allowedDomain, StringComparison.InvariantCultureIgnoreCase)) + if ((allowedDomain[0] == '.' && rd.DnsSafeHost.EndsWith(allowedDomain, StringComparison.InvariantCultureIgnoreCase)) || + rd.DnsSafeHost.Equals(allowedDomain, StringComparison.InvariantCultureIgnoreCase)) { found = true; break; @@ -131,4 +130,25 @@ public ActionResult Signin([FromQuery] Uri rd) return Challenge(new AuthenticationProperties { RedirectUri = rd.ToString() }); } + + [HttpGet("signout")] + [Authorize] + public IActionResult SignOut([FromQuery] string rd) + { + return SignOut(new AuthenticationProperties { RedirectUri = rd }); + } + + [HttpGet("userinfo")] + [Authorize] + public IActionResult UserInfo() + { + return Ok(HttpContext.User.Claims.Select(x => new { Name = x.Type, x.Value })); + } + + [HttpGet("robots.txt")] + [AllowAnonymous] + public IActionResult Robots() + { + return Ok("User-agent: *\r\nDisallow: /"); + } } \ No newline at end of file diff --git a/src/oidc-guard/Program.cs b/src/oidc-guard/Program.cs index f62e60e..03e36d8 100644 --- a/src/oidc-guard/Program.cs +++ b/src/oidc-guard/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OpenIdConnect; @@ -8,6 +9,7 @@ using Microsoft.Net.Http.Headers; using oidc_guard.Services; using Prometheus; +using System.IdentityModel.Tokens.Jwt; namespace oidc_guard; @@ -19,7 +21,7 @@ public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); - Settings settings = builder.Configuration.GetSection("Settings").Get()!; + var settings = builder.Configuration.GetSection("Settings").Get()!; builder.Services.AddSingleton(settings); if (builder.Environment.IsProduction()) @@ -39,6 +41,8 @@ public static void Main(string[] args) o.OnDeleteCookie = cookieContext => cookieContext.CookieOptions.SameSite = settings.CookieSameSiteMode; }); + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + builder.Services.AddAuthentication(o => { o.DefaultScheme = AuthenticationScheme; @@ -53,17 +57,26 @@ public static void Main(string[] args) { o.ClientId = settings.ClientId; o.ClientSecret = settings.ClientSecret; + o.CorrelationCookie.Name = settings.CookieName; o.MetadataAddress = settings.OpenIdProviderConfigurationUrl; o.NonceCookie.Name = settings.CookieName; o.ResponseType = OpenIdConnectResponseType.Code; o.SaveTokens = settings.SaveTokensInCookie; o.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(30); + o.Scope.Clear(); + foreach (var scope in settings.Scopes) + { + o.Scope.Add(scope); + } + o.ClaimActions.Clear(); + o.ClaimActions.MapAllExcept("nonce", /*"aud",*/ "azp", "acr", "iss", "iat", "nbf", "exp", "at_hash", "c_hash", "ipaddr", "platf", "ver"); }) .AddJwtBearer(o => { o.MetadataAddress = settings.OpenIdProviderConfigurationUrl; o.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(30); o.TokenValidationParameters.ValidateAudience = settings.ValidateAudience; + o.TokenValidationParameters.ValidAudiences = settings.ValidAudiences; o.TokenValidationParameters.ValidateIssuer = settings.ValidateIssuer; o.TokenValidationParameters.ValidIssuers = settings.ValidIssuers; }) diff --git a/src/oidc-guard/Settings.cs b/src/oidc-guard/Settings.cs index cddaabb..ca39088 100644 --- a/src/oidc-guard/Settings.cs +++ b/src/oidc-guard/Settings.cs @@ -2,6 +2,7 @@ public class Settings { + public bool EnableAccessTokenInQueryParameter { get; set; } public bool SaveTokensInCookie { get; set; } public bool SkipAuthPreflight { get; set; } public bool ValidateAudience { get; set; } @@ -10,12 +11,13 @@ public class Settings public SameSiteMode CookieSameSiteMode { get; set; } public string ClientId { get; set; } = null!; public string ClientSecret { get; set; } = null!; - public string? CookieDomain { get; set; } public string CookieName { get; set; } = "oidc-guard"; public string OpenIdProviderConfigurationUrl { get; set; } = null!; - public string[]? AllowedRedirectDomains { get; set; } - public string[]? ValidIssuers { get; set; } + public string? CookieDomain { get; set; } public string? Host { get; set; } public string? Scheme { get; set; } - public bool EnableAccessTokenInQueryParameter { get; set; } + public string[] Scopes { get; set; } + public string[]? AllowedRedirectDomains { get; set; } + public string[]? ValidAudiences { get; set; } + public string[]? ValidIssuers { get; set; } } diff --git a/tests/oidc-guard-tests/AllowedRedirectDomainTests.cs b/tests/oidc-guard-tests/AllowedRedirectDomainTests.cs index fee9816..cc75b9a 100644 --- a/tests/oidc-guard-tests/AllowedRedirectDomainTests.cs +++ b/tests/oidc-guard-tests/AllowedRedirectDomainTests.cs @@ -1,6 +1,8 @@ -using oidc_guard; +using FluentAssertions; +using oidc_guard_tests.Infra; using System.Net; using System.Web; +using Xunit; namespace oidc_guard_tests; @@ -31,7 +33,7 @@ public class AllowedRedirectDomainTests public async Task Signin(string query, string[]? allowedRedirectDomains, HttpStatusCode status) { - var client = AuthTests.GetClient(x => x.AllowedRedirectDomains = allowedRedirectDomains); + var client = AuthTestsHelpers.GetClient(x => x.AllowedRedirectDomains = allowedRedirectDomains); var response = await client.GetAsync($"/signin?rd={HttpUtility.UrlEncode(query)}"); diff --git a/tests/oidc-guard-tests/AuthTests.cs b/tests/oidc-guard-tests/AuthTests.cs index fbf6a5a..0fdd78d 100644 --- a/tests/oidc-guard-tests/AuthTests.cs +++ b/tests/oidc-guard-tests/AuthTests.cs @@ -1,67 +1,25 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Logging; -using Microsoft.IdentityModel.Protocols; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using FluentAssertions; using Microsoft.Net.Http.Headers; using oidc_guard; +using oidc_guard_tests.Infra; using System.Net; +using System.Net.Http.Json; using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Nodes; +using Xunit; namespace oidc_guard_tests; public class AuthTests { - public static HttpClient GetClient(Action? settingsAction = null, bool allowAutoRedirect = false) + [Fact] + public async Task Unauthorized() { - IdentityModelEventSource.ShowPII = true; - - var settings = new Settings() - { - ClientId = FakeJwtIssuer.Audience, - ClientSecret = "secret", - OpenIdProviderConfigurationUrl = "https://inmemory.microsoft.com/common/.well-known/openid-configuration" - }; - - settingsAction?.Invoke(settings); - - var factory = new MyWebApplicationFactory(settings) - .WithWebHostBuilder(builder => - { - builder.ConfigureServices((webHost, services) => - { - services.AddSingleton(); - services.AddTransient(); - - services.PostConfigure(JwtBearerDefaults.AuthenticationScheme, options => - { - options.Configuration = null; - options.MetadataAddress = null; - options.ConfigurationManager = new ConfigurationManager( - settings.OpenIdProviderConfigurationUrl, - new OpenIdConnectConfigurationRetriever(), - new TestServerDocumentRetriever() - ); - }); - - services.PostConfigure(OpenIdConnectDefaults.AuthenticationScheme, options => - { - options.Configuration = null; - options.MetadataAddress = null; - options.ConfigurationManager = new ConfigurationManager( - settings.OpenIdProviderConfigurationUrl, - new OpenIdConnectConfigurationRetriever(), - new TestServerDocumentRetriever() - ); - }); - }); - }); - - factory.ClientOptions.AllowAutoRedirect = allowAutoRedirect; + var _client = AuthTestsHelpers.GetClient(); - return factory.CreateDefaultClient(); + var response = await _client.GetAsync("/auth"); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } public static IEnumerable GetTests() @@ -321,7 +279,7 @@ public static IEnumerable GetInjectClaimsTests() [MemberData(nameof(GetInjectClaimsTests))] public async Task Auth(string query, List claims, HttpStatusCode status, List? expectedHeaders = null) { - var _client = GetClient(); + var _client = AuthTestsHelpers.GetClient(); _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Authorization, FakeJwtIssuer.GenerateBearerJwtToken(claims)); @@ -338,68 +296,6 @@ public async Task Auth(string query, List claims, HttpStatusCode status, } } - [Fact] - public async Task Unauthorized() - { - var _client = GetClient(); - - var response = await _client.GetAsync("/auth"); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task SkipAuthPreflight() - { - var _client = GetClient(x => { x.SkipAuthPreflight = true; }); - - _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Origin, "localhost"); - _client.DefaultRequestHeaders.TryAddWithoutValidation(CustomHeaderNames.OriginalMethod, "OPTIONS"); - _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestHeaders, "origin, x-requested-with"); - _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestMethod, "DELETE"); - - var response = await _client.GetAsync("/auth"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task SkipAuthPreflightDisabled() - { - var _client = GetClient(x => { x.SkipAuthPreflight = false; }); - - _client.DefaultRequestHeaders.TryAddWithoutValidation(CustomHeaderNames.OriginalMethod, "OPTIONS"); - _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestHeaders, "origin, x-requested-with"); - _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestMethod, "DELETE"); - - var response = await _client.GetAsync("/auth"); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task SkipAuthPreflightMissingMethod() - { - var _client = GetClient(x => { x.SkipAuthPreflight = true; }); - - _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Origin, "localhost"); - _client.DefaultRequestHeaders.TryAddWithoutValidation(CustomHeaderNames.OriginalMethod, "OPTIONS"); - _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestHeaders, "origin, x-requested-with"); - - var response = await _client.GetAsync("/auth"); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task SkipAuthPreflightMissingRequestHeaders() - { - var _client = GetClient(x => { x.SkipAuthPreflight = true; }); - - _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Origin, "localhost"); - _client.DefaultRequestHeaders.TryAddWithoutValidation(CustomHeaderNames.OriginalMethod, "OPTIONS"); - _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestMethod, "DELETE"); - - var response = await _client.GetAsync("/auth"); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - public static IEnumerable GetTokenAsQueryParameterTests() { return new List @@ -473,7 +369,7 @@ public static IEnumerable GetTokenAsQueryParameterTests() [MemberData(nameof(GetTokenAsQueryParameterTests))] public async Task TokenInQueryParamTests(string query, List claims, HttpStatusCode status, Dictionary requestHeaders, bool addAuthorizationHeader = false) { - var _client = GetClient(x => { x.EnableAccessTokenInQueryParameter = true; }); + var _client = AuthTestsHelpers.GetClient(x => { x.EnableAccessTokenInQueryParameter = true; }); foreach (var header in requestHeaders) { @@ -490,27 +386,32 @@ public async Task TokenInQueryParamTests(string query, List claims, HttpS } [Fact] - public async Task Signin() + public async Task Robots() { - var _client = GetClient(allowAutoRedirect: true); + var _client = AuthTestsHelpers.GetClient(); - var response = await _client.GetAsync("/signin?rd=/auth"); - response.StatusCode.Should().Be(HttpStatusCode.Found); - - _client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", response.Headers.GetValues("Set-Cookie")); + var response = await _client.GetAsync("/robots.txt"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Be("User-agent: *\r\nDisallow: /"); + } - var response2 = await _client.GetAsync(response.Headers.Location); - response2.StatusCode.Should().Be(HttpStatusCode.Found); - response2.Headers.Location.Should().Be("/auth"); + [Fact] + public async Task UserInfo() + { + var _client = AuthTestsHelpers.GetClient(); - _client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", response2.Headers.GetValues("Set-Cookie")); + var claims = new List() + { + new Claim("username", "test") + }; - var response3 = await _client.GetAsync(response2.Headers.Location); - response3.StatusCode.Should().Be(HttpStatusCode.OK); + _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Authorization, FakeJwtIssuer.GenerateBearerJwtToken(claims)); - _client.DefaultRequestHeaders.Clear(); + var response = await _client.GetAsync("/userinfo"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var json = await response.Content.ReadFromJsonAsync(); - var response4 = await _client.GetAsync(response2.Headers.Location); - response4.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + json.RootElement[0].GetProperty("name").GetString().Should().Be("username"); + json.RootElement[0].GetProperty("value").GetString().Should().Be("test"); } } diff --git a/tests/oidc-guard-tests/Infra/AuthTestsHelpers.cs b/tests/oidc-guard-tests/Infra/AuthTestsHelpers.cs new file mode 100644 index 0000000..f1828fe --- /dev/null +++ b/tests/oidc-guard-tests/Infra/AuthTestsHelpers.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Logging; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using oidc_guard; + +namespace oidc_guard_tests.Infra; + +internal static class AuthTestsHelpers +{ + public static HttpClient GetClient(Action? settingsAction = null, bool allowAutoRedirect = false) + { + IdentityModelEventSource.ShowPII = true; + + var settings = new Settings() + { + ClientId = FakeJwtIssuer.Audience, + ClientSecret = "secret", + OpenIdProviderConfigurationUrl = "https://inmemory.microsoft.com/common/.well-known/openid-configuration", + Scopes = new[] {"openid", "profile"} + }; + + settingsAction?.Invoke(settings); + + var factory = new MyWebApplicationFactory(settings) + .WithWebHostBuilder(builder => + { + builder.ConfigureServices((webHost, services) => + { + services.AddSingleton(); + services.AddTransient(); + + services.PostConfigure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.Configuration = null; + options.MetadataAddress = null; + options.ConfigurationManager = new ConfigurationManager( + settings.OpenIdProviderConfigurationUrl, + new OpenIdConnectConfigurationRetriever(), + new TestServerDocumentRetriever() + ); + }); + + services.PostConfigure(OpenIdConnectDefaults.AuthenticationScheme, options => + { + options.Configuration = null; + options.MetadataAddress = null; + options.ConfigurationManager = new ConfigurationManager( + settings.OpenIdProviderConfigurationUrl, + new OpenIdConnectConfigurationRetriever(), + new TestServerDocumentRetriever() + ); + }); + }); + }); + + factory.ClientOptions.AllowAutoRedirect = allowAutoRedirect; + + return factory.CreateDefaultClient(); + } +} \ No newline at end of file diff --git a/tests/oidc-guard-tests/FakeJwtIssuer.cs b/tests/oidc-guard-tests/Infra/FakeJwtIssuer.cs similarity index 82% rename from tests/oidc-guard-tests/FakeJwtIssuer.cs rename to tests/oidc-guard-tests/Infra/FakeJwtIssuer.cs index 4995253..83961cc 100644 --- a/tests/oidc-guard-tests/FakeJwtIssuer.cs +++ b/tests/oidc-guard-tests/Infra/FakeJwtIssuer.cs @@ -4,7 +4,7 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -namespace oidc_guard_tests; +namespace oidc_guard_tests.Infra; // https://stebet.net/mocking-jwt-tokens-in-asp-net-core-integration-tests/ public static class FakeJwtIssuer @@ -23,9 +23,9 @@ static FakeJwtIssuer() { RSA rsa = new RSACryptoServiceProvider(2048); - CertificateRequest certificateRequest = new CertificateRequest("CN=MyCertificate", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var certificateRequest = new CertificateRequest("CN=MyCertificate", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - X509Certificate2 certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1)); + var certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1)); SecurityKey = new X509SecurityKey(certificate); diff --git a/tests/oidc-guard-tests/MyWebApplicationFactory.cs b/tests/oidc-guard-tests/Infra/MyWebApplicationFactory.cs similarity index 95% rename from tests/oidc-guard-tests/MyWebApplicationFactory.cs rename to tests/oidc-guard-tests/Infra/MyWebApplicationFactory.cs index c4be46d..c8cb5b3 100644 --- a/tests/oidc-guard-tests/MyWebApplicationFactory.cs +++ b/tests/oidc-guard-tests/Infra/MyWebApplicationFactory.cs @@ -5,7 +5,7 @@ using System.Text; using System.Text.Json; -namespace oidc_guard_tests; +namespace oidc_guard_tests.Infra; internal class MyWebApplicationFactory : WebApplicationFactory where TEntryPoint : class { diff --git a/tests/oidc-guard-tests/SigninMiddleware.cs b/tests/oidc-guard-tests/Infra/SigninMiddleware.cs similarity index 91% rename from tests/oidc-guard-tests/SigninMiddleware.cs rename to tests/oidc-guard-tests/Infra/SigninMiddleware.cs index 604e67e..0e5b1cd 100644 --- a/tests/oidc-guard-tests/SigninMiddleware.cs +++ b/tests/oidc-guard-tests/Infra/SigninMiddleware.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Http; using System.Security.Claims; -namespace oidc_guard_tests; +namespace oidc_guard_tests.Infra; public class SigninMiddleware : IMiddleware { @@ -25,8 +25,8 @@ public async Task InvokeAsync(HttpContext httpContext, RequestDelegate _next) return; } - TimeSpan span = (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc)); - double unixTime = span.TotalSeconds; + var span = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); + var unixTime = span.TotalSeconds; var token = FakeJwtIssuer.GenerateJwtToken(new List() { new Claim("nonce", nonce), new Claim("iat", unixTime.ToString()), new Claim("sub", FakeJwtIssuer.Audience) }); diff --git a/tests/oidc-guard-tests/SigninStartupFilter.cs b/tests/oidc-guard-tests/Infra/SigninStartupFilter.cs similarity index 91% rename from tests/oidc-guard-tests/SigninStartupFilter.cs rename to tests/oidc-guard-tests/Infra/SigninStartupFilter.cs index aeb1eb7..a1665f0 100644 --- a/tests/oidc-guard-tests/SigninStartupFilter.cs +++ b/tests/oidc-guard-tests/Infra/SigninStartupFilter.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -namespace oidc_guard_tests; +namespace oidc_guard_tests.Infra; public class SigninStartupFilter : IStartupFilter { diff --git a/tests/oidc-guard-tests/TestServerDocumentRetriever.cs b/tests/oidc-guard-tests/Infra/TestServerDocumentRetriever.cs similarity index 98% rename from tests/oidc-guard-tests/TestServerDocumentRetriever.cs rename to tests/oidc-guard-tests/Infra/TestServerDocumentRetriever.cs index abd2aed..c79da05 100644 --- a/tests/oidc-guard-tests/TestServerDocumentRetriever.cs +++ b/tests/oidc-guard-tests/Infra/TestServerDocumentRetriever.cs @@ -1,6 +1,6 @@ using Microsoft.IdentityModel.Protocols; -namespace oidc_guard_tests; +namespace oidc_guard_tests.Infra; public class TestServerDocumentRetriever : IDocumentRetriever { diff --git a/tests/oidc-guard-tests/SignInOutTests.cs b/tests/oidc-guard-tests/SignInOutTests.cs new file mode 100644 index 0000000..2b19e4a --- /dev/null +++ b/tests/oidc-guard-tests/SignInOutTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using oidc_guard_tests.Infra; +using System.Net; +using Xunit; + +namespace oidc_guard_tests +{ + public class SingInOutTests + { + [Fact] + public async Task SignIn() + { + var _client = AuthTestsHelpers.GetClient(allowAutoRedirect: true); + + var response = await _client.GetAsync("/signin?rd=/auth"); + response.StatusCode.Should().Be(HttpStatusCode.Found); + + _client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", response.Headers.GetValues("Set-Cookie")); + + var response2 = await _client.GetAsync(response.Headers.Location); + response2.StatusCode.Should().Be(HttpStatusCode.Found); + response2.Headers.Location.Should().Be("/auth"); + + _client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", response2.Headers.GetValues("Set-Cookie")); + + var response3 = await _client.GetAsync(response2.Headers.Location); + response3.StatusCode.Should().Be(HttpStatusCode.OK); + + _client.DefaultRequestHeaders.Clear(); + + var response4 = await _client.GetAsync(response2.Headers.Location); + response4.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task SignOut() + { + var _client = AuthTestsHelpers.GetClient(allowAutoRedirect: true); + + var response = await _client.GetAsync("/signin?rd=/auth"); + response.StatusCode.Should().Be(HttpStatusCode.Found); + + _client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", response.Headers.GetValues("Set-Cookie")); + + var response2 = await _client.GetAsync(response.Headers.Location); + response2.StatusCode.Should().Be(HttpStatusCode.Found); + response2.Headers.Location.Should().Be("/auth"); + + _client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", response2.Headers.GetValues("Set-Cookie")); + + var response3 = await _client.GetAsync(response2.Headers.Location); + response3.StatusCode.Should().Be(HttpStatusCode.OK); + + var response4 = await _client.GetAsync("/signout?rd=/auth"); + response4.StatusCode.Should().Be(HttpStatusCode.Found); + + _client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", response4.Headers.GetValues("Set-Cookie")); + + var response5 = await _client.GetAsync(response4.Headers.Location); + response5.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + } +} diff --git a/tests/oidc-guard-tests/SkipAuthPreflight.cs b/tests/oidc-guard-tests/SkipAuthPreflight.cs new file mode 100644 index 0000000..62534fa --- /dev/null +++ b/tests/oidc-guard-tests/SkipAuthPreflight.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using Microsoft.Net.Http.Headers; +using oidc_guard; +using oidc_guard_tests.Infra; +using System.Net; +using Xunit; + +namespace oidc_guard_tests +{ + public class SkipAuthPreflightTests + { + [Fact] + public async Task SkipAuthPreflight() + { + var _client = AuthTestsHelpers.GetClient(x => { x.SkipAuthPreflight = true; }); + + _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Origin, "localhost"); + _client.DefaultRequestHeaders.TryAddWithoutValidation(CustomHeaderNames.OriginalMethod, "OPTIONS"); + _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestHeaders, "origin, x-requested-with"); + _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestMethod, "DELETE"); + + var response = await _client.GetAsync("/auth"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task SkipAuthPreflightDisabled() + { + var _client = AuthTestsHelpers.GetClient(x => { x.SkipAuthPreflight = false; }); + + _client.DefaultRequestHeaders.TryAddWithoutValidation(CustomHeaderNames.OriginalMethod, "OPTIONS"); + _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestHeaders, "origin, x-requested-with"); + _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestMethod, "DELETE"); + + var response = await _client.GetAsync("/auth"); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task SkipAuthPreflightMissingMethod() + { + var _client = AuthTestsHelpers.GetClient(x => { x.SkipAuthPreflight = true; }); + + _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Origin, "localhost"); + _client.DefaultRequestHeaders.TryAddWithoutValidation(CustomHeaderNames.OriginalMethod, "OPTIONS"); + _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestHeaders, "origin, x-requested-with"); + + var response = await _client.GetAsync("/auth"); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task SkipAuthPreflightMissingRequestHeaders() + { + var _client = AuthTestsHelpers.GetClient(x => { x.SkipAuthPreflight = true; }); + + _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.Origin, "localhost"); + _client.DefaultRequestHeaders.TryAddWithoutValidation(CustomHeaderNames.OriginalMethod, "OPTIONS"); + _client.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.AccessControlRequestMethod, "DELETE"); + + var response = await _client.GetAsync("/auth"); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + } +} diff --git a/tests/oidc-guard-tests/Usings.cs b/tests/oidc-guard-tests/Usings.cs deleted file mode 100644 index d6558d2..0000000 --- a/tests/oidc-guard-tests/Usings.cs +++ /dev/null @@ -1,3 +0,0 @@ -global using Xunit; -global using FluentAssertions; -global using Moq; \ No newline at end of file