From b9f626b37f267f4acf9811987d932d79d2e6f5d3 Mon Sep 17 00:00:00 2001 From: Tom Weihe <65891188+ToWe0815@users.noreply.github.com> Date: Mon, 28 Oct 2024 10:31:57 +0100 Subject: [PATCH] feat(fake-auth): add callback event to fake auth handler (#800) --- .../Context/LocalFakeZitadelAuthContext.cs | 91 +++++++ .../Events/LocalFakeZitadelEvents.cs | 12 + .../Handler/LocalFakeZitadelHandler.cs | 107 +++++---- .../Options/LocalFakeZitadelOptions.cs | 115 ++++----- .../ZitadelFakeAuthenticationHandler.Test.cs | 116 +++++---- .../FakeAuthenticationHandlerWebFactory.cs | 223 +++++++++--------- 6 files changed, 403 insertions(+), 261 deletions(-) create mode 100644 src/Zitadel/Authentication/Events/Context/LocalFakeZitadelAuthContext.cs create mode 100644 src/Zitadel/Authentication/Events/LocalFakeZitadelEvents.cs diff --git a/src/Zitadel/Authentication/Events/Context/LocalFakeZitadelAuthContext.cs b/src/Zitadel/Authentication/Events/Context/LocalFakeZitadelAuthContext.cs new file mode 100644 index 00000000..d67c01f3 --- /dev/null +++ b/src/Zitadel/Authentication/Events/Context/LocalFakeZitadelAuthContext.cs @@ -0,0 +1,91 @@ +using System.Security.Claims; + +namespace Zitadel.Authentication.Events.Context +{ + public class LocalFakeZitadelAuthContext + { + /// + /// Constructor. + /// + /// The created ClaimsIdentity. + public LocalFakeZitadelAuthContext(ClaimsIdentity identity) + { + Identity = identity; + } + + /// + /// The created ClaimsIdentity. + /// + public ClaimsIdentity Identity { get; init; } + + /// + /// The claims of the created ClaimsIdentity. + /// + public IEnumerable Claims => Identity.Claims; + + /// + /// The "user-id" of the fake user. + /// Either set by the options or via HTTP header. + /// + public string FakeZitadelId => new ClaimsPrincipal(Identity).FindFirstValue("sub")!; + + /// + /// Add a claim to the list. + /// This is a convenience method for modifying . + /// + /// Type of the claim (examples: ). + /// The value. + /// Type of the value (examples: ). + /// The issuer for this claim. + /// The original issuer of this claim. + /// The for chaining. + public LocalFakeZitadelAuthContext AddClaim( + string type, + string value, + string? valueType = null, + string? issuer = null, + string? originalIssuer = null) => AddClaim(new(type, value, valueType, issuer, originalIssuer)); + + /// + /// Add a claim to the list. + /// This is a convenience method for modifying . + /// + /// The claim to add. + /// The for chaining. + public LocalFakeZitadelAuthContext AddClaim(Claim claim) + { + Identity.AddClaim(claim); + return this; + } + + /// + /// Add a single role to the identity's claims. + /// Note: the roles are actually "claims" but this method exists + /// for convenience. + /// + /// The role to add. + /// The for chaining. + public LocalFakeZitadelAuthContext AddRole(string role) + { + Identity.AddClaim(new(ClaimTypes.Role, role)); + return this; + } + + /// + /// Add multiple roles to the identity's claims. + /// Note: the roles are actually "claims" but this method exists + /// for convenience. + /// + /// The roles to add. + /// The for chaining. + public LocalFakeZitadelAuthContext AddRoles(string[] roles) + { + foreach (var role in roles) + { + AddRole(role); + } + + return this; + } + } +} diff --git a/src/Zitadel/Authentication/Events/LocalFakeZitadelEvents.cs b/src/Zitadel/Authentication/Events/LocalFakeZitadelEvents.cs new file mode 100644 index 00000000..2aaed257 --- /dev/null +++ b/src/Zitadel/Authentication/Events/LocalFakeZitadelEvents.cs @@ -0,0 +1,12 @@ +using Zitadel.Authentication.Events.Context; +using Zitadel.Authentication.Handler; + +namespace Zitadel.Authentication.Events; + +public class LocalFakeZitadelEvents +{ + /// + /// Invoked after a ClaimsIdentity has been generated in the . + /// + public Func OnZitadelFakeAuth { get; set; } = context => Task.CompletedTask; +} diff --git a/src/Zitadel/Authentication/Handler/LocalFakeZitadelHandler.cs b/src/Zitadel/Authentication/Handler/LocalFakeZitadelHandler.cs index 09eaf3d0..00f4c2f3 100644 --- a/src/Zitadel/Authentication/Handler/LocalFakeZitadelHandler.cs +++ b/src/Zitadel/Authentication/Handler/LocalFakeZitadelHandler.cs @@ -1,52 +1,55 @@ -using System.Security.Claims; -using System.Text.Encodings.Web; - -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -using Zitadel.Authentication.Options; - -namespace Zitadel.Authentication.Handler; - -#if NET8_0_OR_GREATER -internal class LocalFakeZitadelHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder) - : AuthenticationHandler(options, logger, encoder) -#else -internal class LocalFakeZitadelHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder, - ISystemClock clock) - : AuthenticationHandler(options, logger, encoder, clock) -#endif -{ - private const string FakeAuthHeader = "x-zitadel-fake-auth"; - private const string FakeUserIdHeader = "x-zitadel-fake-user-id"; - - protected override Task HandleAuthenticateAsync() - { - if (Context.Request.Headers.TryGetValue(FakeAuthHeader, out var value) && value == "false") - { - return Task.FromResult(AuthenticateResult.Fail($"""The {FakeAuthHeader} was set with value "false".""")); - } - - var hasId = Context.Request.Headers.TryGetValue(FakeUserIdHeader, out var forceUserId); - - var claims = new List - { - new(ClaimTypes.NameIdentifier, hasId ? forceUserId.ToString() : Options.FakeZitadelOptions.FakeZitadelId), - new("sub", hasId ? forceUserId.ToString() : Options.FakeZitadelOptions.FakeZitadelId), - }.Concat(Options.FakeZitadelOptions.AdditionalClaims) - .Concat(Options.FakeZitadelOptions.Roles.Select(r => new Claim(ClaimTypes.Role, r))); - - var identity = new ClaimsIdentity(claims, ZitadelDefaults.FakeAuthenticationScheme); - - return Task.FromResult( - AuthenticateResult.Success( - new(new(identity), ZitadelDefaults.FakeAuthenticationScheme))); - } -} +using System.Security.Claims; +using System.Text.Encodings.Web; + +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Zitadel.Authentication.Options; + +namespace Zitadel.Authentication.Handler; + +#if NET8_0_OR_GREATER +internal class LocalFakeZitadelHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +#else +internal class LocalFakeZitadelHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) + : AuthenticationHandler(options, logger, encoder, clock) +#endif +{ + private const string FakeAuthHeader = "x-zitadel-fake-auth"; + private const string FakeUserIdHeader = "x-zitadel-fake-user-id"; + + protected override Task HandleAuthenticateAsync() + { + if (Context.Request.Headers.TryGetValue(FakeAuthHeader, out var value) && value == "false") + { + return Task.FromResult(AuthenticateResult.Fail($"""The {FakeAuthHeader} was set with value "false".""")); + } + + var hasId = Context.Request.Headers.TryGetValue(FakeUserIdHeader, out var forceUserId); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, hasId ? forceUserId.ToString() : Options.FakeZitadelOptions.FakeZitadelId), + new("sub", hasId ? forceUserId.ToString() : Options.FakeZitadelOptions.FakeZitadelId), + }.Concat(Options.FakeZitadelOptions.AdditionalClaims) + .Concat(Options.FakeZitadelOptions.Roles.Select(r => new Claim(ClaimTypes.Role, r))); + + var identity = new ClaimsIdentity(claims, ZitadelDefaults.FakeAuthenticationScheme); + + // Callback to enable users to manipulate the ClaimsIdentity before it is used. + Options.FakeZitadelOptions.Events.OnZitadelFakeAuth.Invoke(new(identity)); + + return Task.FromResult( + AuthenticateResult.Success( + new(new(identity), ZitadelDefaults.FakeAuthenticationScheme))); + } +} diff --git a/src/Zitadel/Authentication/Options/LocalFakeZitadelOptions.cs b/src/Zitadel/Authentication/Options/LocalFakeZitadelOptions.cs index eb6063b4..31e488a5 100644 --- a/src/Zitadel/Authentication/Options/LocalFakeZitadelOptions.cs +++ b/src/Zitadel/Authentication/Options/LocalFakeZitadelOptions.cs @@ -1,54 +1,61 @@ -using System.Security.Claims; - -namespace Zitadel.Authentication.Options -{ - public class LocalFakeZitadelOptions - { - /// - /// The "user-id" of the fake user. - /// This populates the "sub" and "nameidentifier" claims. - /// - public string FakeZitadelId { get; set; } = string.Empty; - - /// - /// A list of additional claims to add to the identity. - /// - public IList AdditionalClaims { get; set; } = new List(); - - /// - /// List of roles that are attached to the identity. - /// Note: the roles are actually "claims" but this list exists - /// for convenience. - /// - public IEnumerable Roles { get; set; } = new List(); - - /// - /// Add a claim to the list. - /// This is a convenience method for modifying . - /// - /// Type of the claim (examples: ). - /// The value. - /// Type of the value (examples: ). - /// The issuer for this claim. - /// The original issuer of this claim. - /// The for chaining. - public LocalFakeZitadelOptions AddClaim( - string type, - string value, - string? valueType = null, - string? issuer = null, - string? originalIssuer = null) => AddClaim(new(type, value, valueType, issuer, originalIssuer)); - - /// - /// Add a claim to the list. - /// This is a convenience method for modifying . - /// - /// The claim to add. - /// The for chaining. - public LocalFakeZitadelOptions AddClaim(Claim claim) - { - AdditionalClaims.Add(claim); - return this; - } - } -} +using System.Security.Claims; + +using Zitadel.Authentication.Events; + +namespace Zitadel.Authentication.Options +{ + public class LocalFakeZitadelOptions + { + /// + /// The "user-id" of the fake user. + /// This populates the "sub" and "nameidentifier" claims. + /// + public string FakeZitadelId { get; set; } = string.Empty; + + /// + /// A list of additional claims to add to the identity. + /// + public IList AdditionalClaims { get; set; } = new List(); + + /// + /// List of roles that are attached to the identity. + /// Note: the roles are actually "claims" but this list exists + /// for convenience. + /// + public IEnumerable Roles { get; set; } = new List(); + + /// + /// Gets or sets the used to enable mocking authentication data dynamically. + /// + public LocalFakeZitadelEvents Events { get; set; } = new LocalFakeZitadelEvents(); + + /// + /// Add a claim to the list. + /// This is a convenience method for modifying . + /// + /// Type of the claim (examples: ). + /// The value. + /// Type of the value (examples: ). + /// The issuer for this claim. + /// The original issuer of this claim. + /// The for chaining. + public LocalFakeZitadelOptions AddClaim( + string type, + string value, + string? valueType = null, + string? issuer = null, + string? originalIssuer = null) => AddClaim(new(type, value, valueType, issuer, originalIssuer)); + + /// + /// Add a claim to the list. + /// This is a convenience method for modifying . + /// + /// The claim to add. + /// The for chaining. + public LocalFakeZitadelOptions AddClaim(Claim claim) + { + AdditionalClaims.Add(claim); + return this; + } + } +} diff --git a/tests/Zitadel.Test/Authentication/ZitadelFakeAuthenticationHandler.Test.cs b/tests/Zitadel.Test/Authentication/ZitadelFakeAuthenticationHandler.Test.cs index 957d9606..8312e26c 100644 --- a/tests/Zitadel.Test/Authentication/ZitadelFakeAuthenticationHandler.Test.cs +++ b/tests/Zitadel.Test/Authentication/ZitadelFakeAuthenticationHandler.Test.cs @@ -1,49 +1,67 @@ -using System.Net; -using System.Net.Http.Json; -using System.Security.Claims; - -using FluentAssertions; - -using Xunit; - -using Zitadel.Test.WebFactories; - -namespace Zitadel.Test.Authentication; - -public class ZitadelFakeAuthenticationHandler(FakeAuthenticationHandlerWebFactory factory) - : IClassFixture -{ - [Fact] - public async Task Should_Be_Able_To_Call_Unauthorized_Endpoint() - { - var client = factory.CreateClient(); - var result = - await client.GetFromJsonAsync("/unauthed", typeof(AuthenticationHandlerWebFactory.Unauthed)) as - AuthenticationHandlerWebFactory.Unauthed; - result.Should().NotBeNull(); - result?.Ping.Should().Be("Pong"); - } - - [Fact] - public async Task Should_Return_Unauthorized_With_The_Fail_Header() - { - var client = factory.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, "/authed") - { - Headers = { { "x-zitadel-fake-auth", "false" } }, - }; - var result = await client.SendAsync(request); - result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task Should_Return_Authorized() - { - var client = factory.CreateClient(); - var result = await client.GetFromJsonAsync("/authed", typeof(AuthenticationHandlerWebFactory.Authed)) as - AuthenticationHandlerWebFactory.Authed; - result?.AuthType.Should().Be("ZITADEL-Fake"); - result?.UserId.Should().Be("1234"); - result?.Claims.Should().Contain(claim => claim.Key == ClaimTypes.Role && claim.Value == "User"); - } -} +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; + +using FluentAssertions; + +using Xunit; + +using Zitadel.Test.WebFactories; + +namespace Zitadel.Test.Authentication; + +public class ZitadelFakeAuthenticationHandler(FakeAuthenticationHandlerWebFactory factory) + : IClassFixture +{ + [Fact] + public async Task Should_Be_Able_To_Call_Unauthorized_Endpoint() + { + var client = factory.CreateClient(); + var result = + await client.GetFromJsonAsync("/unauthed", typeof(AuthenticationHandlerWebFactory.Unauthed)) as + AuthenticationHandlerWebFactory.Unauthed; + result.Should().NotBeNull(); + result?.Ping.Should().Be("Pong"); + } + + [Fact] + public async Task Should_Return_Unauthorized_With_The_Fail_Header() + { + var client = factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "/authed") + { + Headers = { { "x-zitadel-fake-auth", "false" } }, + }; + var result = await client.SendAsync(request); + result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Should_Return_Authorized() + { + var client = factory.CreateClient(); + var result = await client.GetFromJsonAsync("/authed", typeof(AuthenticationHandlerWebFactory.Authed)) as + AuthenticationHandlerWebFactory.Authed; + result?.AuthType.Should().Be("ZITADEL-Fake"); + result?.UserId.Should().Be("1234"); + result?.Claims.Should().Contain(claim => claim.Key == ClaimTypes.Role && claim.Value == "User"); + } + + [Fact] + public async Task Should_Trigger_Callback() + { + var client = factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "/authed") + { + Headers = { { "x-zitadel-fake-user-id", "4321" } }, + }; + var result = await client.SendAsync(request); + var content = await result.Content.ReadFromJsonAsync(); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + content?.AuthType.Should().Be("ZITADEL-Fake"); + content?.UserId.Should().Be("4321"); + content?.Claims.Should().Contain(claim => claim.Key == "bar" && claim.Value == "foo"); + content?.Claims.Should().Contain(claim => claim.Key == ClaimTypes.Role && claim.Value == "Admin"); + } +} diff --git a/tests/Zitadel.Test/WebFactories/FakeAuthenticationHandlerWebFactory.cs b/tests/Zitadel.Test/WebFactories/FakeAuthenticationHandlerWebFactory.cs index 39d92de3..36e74c21 100644 --- a/tests/Zitadel.Test/WebFactories/FakeAuthenticationHandlerWebFactory.cs +++ b/tests/Zitadel.Test/WebFactories/FakeAuthenticationHandlerWebFactory.cs @@ -1,106 +1,117 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Claims; - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -using Zitadel.Authentication; -using Zitadel.Extensions; - -namespace Zitadel.Test.WebFactories; - -public class FakeAuthenticationHandlerWebFactory : WebApplicationFactory -{ - #region Startup - - public void ConfigureServices(IServiceCollection services) - { - services - .AddAuthorization() - .AddAuthentication(ZitadelDefaults.FakeAuthenticationScheme) - .AddZitadelFake( - options => - { - options.FakeZitadelId = "1234"; - options.AdditionalClaims = new List { new("foo", "bar"), }; - options.Roles = new List { "User" }; - }); - } - - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - app.UseRouting(); - - app.UseAuthentication(); - app.UseAuthorization(); - - app.UseEndpoints( - endpoints => - { - endpoints.MapGet( - "/unauthed", - async context => { await context.Response.WriteAsJsonAsync(new Unauthed { Ping = "Pong" }); }); - endpoints.MapGet( - "/authed", - async context => - { - await context.Response.WriteAsJsonAsync( - new Authed - { - Ping = "Pong", - AuthType = context.User.Identity?.AuthenticationType ?? string.Empty, - UserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty, - Claims = context.User.Claims.Select( - c => new KeyValuePair(c.Type, c.Value)) - .ToList(), - }); - }) - .RequireAuthorization(); - }); - } - - #endregion - - #region WebApplicationFactory - - protected override IHostBuilder CreateHostBuilder() - => Host - .CreateDefaultBuilder() - .ConfigureWebHostDefaults( - builder => builder - .UseStartup()); - - protected override IHost CreateHost(IHostBuilder builder) - { - builder.UseContentRoot(Directory.GetCurrentDirectory()); - return base.CreateHost(builder); - } - - #endregion - - #region Result Classes - - internal record Unauthed - { - public string Ping { get; init; } = null!; - } - - internal record Authed - { - public string Ping { get; init; } = null!; - - public string AuthType { get; init; } = null!; - - public string UserId { get; init; } = null!; - - public List> Claims { get; init; } = null!; - } - - #endregion -} +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Claims; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using Zitadel.Authentication; +using Zitadel.Extensions; + +namespace Zitadel.Test.WebFactories; + +public class FakeAuthenticationHandlerWebFactory : WebApplicationFactory +{ + #region Startup + + public void ConfigureServices(IServiceCollection services) + { + services + .AddAuthorization() + .AddAuthentication(ZitadelDefaults.FakeAuthenticationScheme) + .AddZitadelFake( + options => + { + options.FakeZitadelId = "1234"; + options.AdditionalClaims = new List { new("foo", "bar"), }; + options.Roles = new List { "User" }; + + options.Events.OnZitadelFakeAuth = context => + { + if (context.FakeZitadelId == "4321") + { + context.AddClaim("bar", "foo"); + context.AddRole("Admin"); + } + + return Task.CompletedTask; + }; + }); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseRouting(); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseEndpoints( + endpoints => + { + endpoints.MapGet( + "/unauthed", + async context => { await context.Response.WriteAsJsonAsync(new Unauthed { Ping = "Pong" }); }); + endpoints.MapGet( + "/authed", + async context => + { + await context.Response.WriteAsJsonAsync( + new Authed + { + Ping = "Pong", + AuthType = context.User.Identity?.AuthenticationType ?? string.Empty, + UserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty, + Claims = context.User.Claims.Select( + c => new KeyValuePair(c.Type, c.Value)) + .ToList(), + }); + }) + .RequireAuthorization(); + }); + } + + #endregion + + #region WebApplicationFactory + + protected override IHostBuilder CreateHostBuilder() + => Host + .CreateDefaultBuilder() + .ConfigureWebHostDefaults( + builder => builder + .UseStartup()); + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.UseContentRoot(Directory.GetCurrentDirectory()); + return base.CreateHost(builder); + } + + #endregion + + #region Result Classes + + internal record Unauthed + { + public string Ping { get; init; } = null!; + } + + internal record Authed + { + public string Ping { get; init; } = null!; + + public string AuthType { get; init; } = null!; + + public string UserId { get; init; } = null!; + + public List> Claims { get; init; } = null!; + } + + #endregion +}