using System.Text.Json; using System.Security.Claims; using System.Text; using System.Net; using Elsa.ExternalAuthentication.Contracts; using Elsa.ExternalAuthentication.Models; using Elsa.ExternalAuthentication.Options; using Elsa.ExternalAuthentication.OpenIdConnect.Models; using Elsa.ExternalAuthentication.OpenIdConnect.Services; using Elsa.ExternalAuthentication.OpenIdConnect.Validation; using Elsa.ExternalAuthentication.Services; using Elsa.ExternalAuthentication.UnitTests.Foundational; using Elsa.ExternalAuthentication.Validation; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.Options; namespace Elsa.ExternalAuthentication.UnitTests.OpenIdConnect; public class OpenIdConnectAdapterTests { [Fact] public void AcceptsDiscoverySettingsAndRejectsIncompleteManualTrust() { var parser = new OpenIdConnectSettingsParser(); var discovery = Parse("""{"mode":"discovery","authority":"https://issuer.example","clientId":"elsa","callbackUri":"https://elsa.example/external-authentication/callback/connection"}"""); var manual = Parse("""{"mode":"manual","authority":"https://issuer.example","clientId":"elsa","callbackUri":"https://elsa.example/external-authentication/callback/connection","issuer":"https://issuer.example"}"""); Assert.True(parser.TryParse(discovery, out var discoverySettings, out var discoveryErrors)); Assert.Equal(OpenIdConnectTrustMode.Discovery, discoverySettings!.TrustMode); Assert.Empty(discoveryErrors); Assert.False(parser.TryParse(manual, out _, out var manualErrors)); Assert.Contains(manualErrors, error => error.Field == "tokenEndpoint"); Assert.Contains(manualErrors, error => error.Field == "signingKeys"); } [Fact] public async Task CreatesAuthorizationCodeRequestWithStateNonceAndProviderPkce() { var adapter = CreateAdapter(new StaticResponseHandler()); var connection = ExternalAuthenticationTestData.CreateConnection("connection", "*", "contoso"); connection.AdapterSettingsVersion = 1; connection.AdapterSettings = Parse("""{"mode":"manual","authority":"https://issuer.example","issuer":"https://issuer.example","authorizationEndpoint":"https://issuer.example/authorize","tokenEndpoint":"https://issuer.example/token","callbackUri":"https://elsa.example/external-authentication/callback/connection","clientId":"elsa","signingKeys":{"keys":[]},"providerPkce":"required","scopes":["openid","profile"]}"""); var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, ConnectionScope.Host, ConnectionValidity.Valid, false, "configuration"); var transaction = new BrokerTransaction { HandleHash = "stored-hash", ProviderNonce = "nonce", PkceChallenge = "client-pkce", CallbackUri = new Uri("https://studio.example/callback"), ClientId = "studio", ReturnPath = "/", TenantId = "tenant-a" }; var request = await adapter.CreateAuthorizationRequestAsync(new ExternalAuthorizationContext(effective, new Dictionary(), transaction, "provider-state", new TestSystemClock(DateTimeOffset.UtcNow))); var query = ParseQuery(request.NavigationUri); Assert.Equal("code", query["response_type"]); Assert.Equal("provider-state", query["state"]); Assert.Equal("nonce", query["nonce"]); Assert.Equal("S256", query["code_challenge_method"]); Assert.NotEmpty(query["code_challenge"]); Assert.NotEmpty(request.ProtectedAdapterState); } [Fact] public void DescriptorIsVersionedAndDeclaresSecretBindingField() { var descriptor = CreateAdapter(new StaticResponseHandler()).Describe(); Assert.Equal(OpenIdConnectExternalAuthenticationAdapter.AdapterType, descriptor.Type); Assert.Equal(1, descriptor.SettingsVersion); Assert.Contains(descriptor.Fields, field => field.Name == "clientSecret" && field.IsSecretBinding); Assert.True(descriptor.Capabilities.SupportsUpstreamLogout); } [Fact] public async Task RejectsProviderCallbackErrorsBeforeAnyTokenProcessing() { var adapter = CreateAdapter(new StaticResponseHandler()); var connection = ExternalAuthenticationTestData.CreateConnection("connection", "*", "contoso"); var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, ConnectionScope.Host, ConnectionValidity.Valid, false, "configuration"); var transaction = new BrokerTransaction { HandleHash = "state", PkceChallenge = "client-pkce", CallbackUri = new Uri("https://studio.example/callback"), ClientId = "studio", ReturnPath = "/", TenantId = "tenant-a" }; var parameters = new Dictionary> { ["error"] = ["access_denied"] }; await Assert.ThrowsAsync(() => adapter.AuthenticateCallbackAsync(new ExternalCallbackContext(effective, new Dictionary(), transaction, "state", parameters, new TestSystemClock(DateTimeOffset.UtcNow))).AsTask()); } [Fact] public async Task RejectsCallbackWhenTheRawCorrelationStateDoesNotMatch() { var connection = CreateConnection(CreateManualSettings()); var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, ConnectionScope.Host, ConnectionValidity.Valid, false, "configuration"); var parameters = new Dictionary> { ["state"] = ["unexpected-state"] }; var adapter = CreateAdapter(new StaticResponseHandler()); await Assert.ThrowsAsync(() => adapter.AuthenticateCallbackAsync(new ExternalCallbackContext(effective, new Dictionary(), CreateTransaction(), "provider-state", parameters, new TestSystemClock(DateTimeOffset.UtcNow))).AsTask()); } [Fact] public async Task AppliesClaimProjectionBoundsWhileKeepingRedactedClaims() { var token = CreateToken(new[] { new Claim("name", "Ada"), new Claim("email", "secret"), new Claim("groups", "toolong") }); var projection = new ClaimProjection( new HashSet(StringComparer.Ordinal) { "name", "email", "groups" }, new HashSet(StringComparer.Ordinal) { "email" }, 2, 6, 9); var result = await AuthenticateAsync(token, projection); Assert.Equal("subject", result.Identity.Subject); Assert.Equal(["Ada"], result.ProjectedClaims["name"]); Assert.Equal(["secret"], result.ProjectedClaims["email"]); Assert.DoesNotContain("groups", result.ProjectedClaims.Keys); } [Fact] public async Task RejectsInvalidSignatureIssuerAudienceAzpExpiryNonceAndMissingCode() { await Assert.ThrowsAsync(() => AuthenticateAsync(CreateToken(signingKey: new SymmetricSecurityKey(Encoding.UTF8.GetBytes("different-signing-key-must-be-long"))))); await Assert.ThrowsAsync(() => AuthenticateAsync(CreateToken(issuer: "https://other.example"))); await Assert.ThrowsAsync(() => AuthenticateAsync(CreateToken(audience: "other-client"))); await Assert.ThrowsAsync(() => AuthenticateAsync(CreateToken(expires: DateTimeOffset.UtcNow.AddMinutes(-5)))); await Assert.ThrowsAsync(() => AuthenticateAsync(CreateToken(nonce: "other-nonce"))); await Assert.ThrowsAsync(() => AuthenticateAsync(CreateToken(), includeCode: false)); await Assert.ThrowsAsync(() => AuthenticateAsync(CreateToken(audiences: ["elsa", "another"], azp: "other-client"))); } [Fact] public async Task SupportsBothUpstreamPkceModes() { var required = await CreateAuthorizationRequestAsync("required"); var disabled = await CreateAuthorizationRequestAsync("disabled"); Assert.Contains("code_challenge_method=S256", required.NavigationUri.Query); Assert.DoesNotContain("code_challenge", disabled.NavigationUri.Query); } [Theory] [InlineData("http://issuer.example")] [InlineData("https://issuer.example", "http://issuer.example/authorize")] public async Task RejectsNonHttpsDiscoveryMetadata(string issuer, string? authorizationEndpoint = null) { var metadata = $$"""{"issuer":"{{issuer}}","authorization_endpoint":"{{authorizationEndpoint ?? "https://issuer.example/authorize"}}","token_endpoint":"https://issuer.example/token","jwks_uri":"https://issuer.example/keys"}"""; var connection = CreateConnection(Parse("""{"mode":"discovery","authority":"https://issuer.example","clientId":"elsa","callbackUri":"https://elsa.example/external-authentication/callback/connection"}""")); var adapter = CreateAdapter(new TokenResponseHandler("", metadata)); var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, ConnectionScope.Host, ConnectionValidity.Valid, false, "configuration"); var transaction = CreateTransaction(); await Assert.ThrowsAsync(() => adapter.CreateAuthorizationRequestAsync(new ExternalAuthorizationContext(effective, new Dictionary(), transaction, "state", new TestSystemClock(DateTimeOffset.UtcNow))).AsTask()); } private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement.Clone(); private static OpenIdConnectExternalAuthenticationAdapter CreateAdapter(HttpMessageHandler handler) { var options = Microsoft.Extensions.Options.Options.Create(new ExternalAuthenticationOptions()); var validator = new OutboundDestinationValidator(options, new StaticPublicDnsResolver()); return new OpenIdConnectExternalAuthenticationAdapter(new ProviderHttpClient(new HttpMessageInvoker(handler), validator, options), new OpenIdConnectSettingsParser()); } private static readonly SymmetricSecurityKey SigningKey = new(Encoding.UTF8.GetBytes("test-signing-key-must-be-at-least-32-bytes")) { KeyId = "test-key" }; private static async Task AuthenticateAsync(string token, ClaimProjection? projection = null, bool includeCode = true) { var connection = CreateConnection(CreateManualSettings()); connection.ClaimProjection = projection ?? new ClaimProjection(new HashSet(StringComparer.Ordinal) { "name" }, new HashSet(), 8, 128, 1024); var adapter = CreateAdapter(new TokenResponseHandler(token)); var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, ConnectionScope.Host, ConnectionValidity.Valid, false, "configuration"); var parameters = new Dictionary> { ["state"] = ["provider-state"] }; if (includeCode) parameters["code"] = ["provider-code"]; return await adapter.AuthenticateCallbackAsync(new ExternalCallbackContext(effective, new Dictionary(), CreateTransaction(), "provider-state", parameters, new TestSystemClock(DateTimeOffset.UtcNow))); } private static async Task CreateAuthorizationRequestAsync(string pkce) { var connection = CreateConnection(CreateManualSettings(pkce)); var adapter = CreateAdapter(new StaticResponseHandler()); var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, ConnectionScope.Host, ConnectionValidity.Valid, false, "configuration"); return await adapter.CreateAuthorizationRequestAsync(new ExternalAuthorizationContext(effective, new Dictionary(), CreateTransaction(), "provider-state", new TestSystemClock(DateTimeOffset.UtcNow))); } private static IdentityProviderConnection CreateConnection(JsonElement settings) { var connection = ExternalAuthenticationTestData.CreateConnection("connection", "*", "contoso"); connection.AdapterSettingsVersion = 1; connection.AdapterSettings = settings; return connection; } private static BrokerTransaction CreateTransaction() => new() { HandleHash = "stored-hash", ProviderNonce = "nonce", PkceChallenge = "client-pkce", CallbackUri = new Uri("https://studio.example/callback"), ClientId = "studio", ReturnPath = "/", TenantId = "tenant-a" }; private static JsonElement CreateManualSettings(string pkce = "disabled") => Parse($$"""{"mode":"manual","authority":"https://issuer.example","issuer":"https://issuer.example","authorizationEndpoint":"https://issuer.example/authorize","tokenEndpoint":"https://issuer.example/token","callbackUri":"https://elsa.example/external-authentication/callback/connection","clientId":"elsa","signingKeys":{"keys":[{"kty":"oct","kid":"test-key","k":"{{Base64UrlEncoder.Encode(SigningKey.Key)}}"}]},"providerPkce":"{{pkce}}"}"""); private static string CreateToken(IEnumerable? claims = null, SymmetricSecurityKey? signingKey = null, string issuer = "https://issuer.example", string audience = "elsa", string nonce = "nonce", DateTimeOffset? expires = null, IReadOnlyCollection? audiences = null, string? azp = null) { var descriptor = new SecurityTokenDescriptor { Issuer = issuer, Audience = audiences is null ? audience : null, Subject = new ClaimsIdentity([new Claim("sub", "subject"), new Claim("nonce", nonce), .. (claims ?? [])]), Expires = (expires ?? DateTimeOffset.UtcNow.AddMinutes(5)).UtcDateTime, SigningCredentials = new SigningCredentials(signingKey ?? SigningKey, SecurityAlgorithms.HmacSha256) }; if (audiences is not null) descriptor.Claims = new Dictionary { ["aud"] = audiences.ToArray(), ["azp"] = azp ?? "elsa" }; return new JsonWebTokenHandler().CreateToken(descriptor); } private static IReadOnlyDictionary ParseQuery(Uri uri) => uri.Query.TrimStart('?') .Split('&', StringSplitOptions.RemoveEmptyEntries) .Select(pair => pair.Split('=', 2)) .ToDictionary(parts => Uri.UnescapeDataString(parts[0]), parts => parts.Length == 2 ? Uri.UnescapeDataString(parts[1]) : string.Empty, StringComparer.Ordinal); private sealed class StaticResponseHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound)); } private sealed class TokenResponseHandler(string token, string? discovery = null) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (request.Method == HttpMethod.Get && discovery is not null) return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(discovery) }); return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent($"{{\"id_token\":\"{token}\"}}") }); } } private sealed class StaticPublicDnsResolver : IOutboundDnsResolver { private static readonly IReadOnlyCollection Addresses = [IPAddress.Parse("8.8.8.8")]; public ValueTask> ResolveAsync(string host, CancellationToken cancellationToken) => ValueTask.FromResult(Addresses); } }