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","discoveryUrl":"https://issuer.example/.well-known/openid-configuration","clientId":"elsa"}"""); var manual = Parse("""{"mode":"manual","clientId":"elsa","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 = 2; connection.AdapterSettings = Parse("""{"mode":"manual","issuer":"https://issuer.example","authorizationEndpoint":"https://issuer.example/authorize","tokenEndpoint":"https://issuer.example/token","clientId":"elsa","clientAuthenticationMethod":"client_secret_basic","signingKeys":{"keys":[]},"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(2, descriptor.SettingsVersion); Assert.Contains(descriptor.Fields, field => field.Name == "clientSecret" && field.IsSecretBinding); Assert.True(descriptor.Capabilities.SupportsUpstreamLogout); } [Fact] public async Task ClientSecretBasicEncodesReservedCredentialsAndOmitsThemFromTheFormBody() { var handler = new CapturingTokenResponseHandler(CreateToken(audience: "client:id")); var adapter = CreateAdapter(handler); var connection = CreateConnection(JsonSerializer.SerializeToElement(new { mode = "manual", clientId = "client:id", clientAuthenticationMethod = "client_secret_basic", issuer = "https://issuer.example", authorizationEndpoint = "https://issuer.example/authorize", tokenEndpoint = "https://issuer.example/token", signingKeys = new { keys = new[] { new { kty = "oct", kid = "test-key", k = Base64UrlEncoder.Encode(SigningKey.Key) } } } })); var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, ConnectionScope.Host, ConnectionValidity.Valid, false, "configuration"); var secrets = new Dictionary { ["clientSecret"] = new(new SensitiveString("secret +/&"), "generation") }; try { await adapter.AuthenticateCallbackAsync(new ExternalCallbackContext(effective, secrets, CreateTransaction(), "provider-state", new Dictionary> { ["state"] = ["provider-state"], ["code"] = ["provider-code"] }, new TestSystemClock(DateTimeOffset.UtcNow))); Assert.Equal("Basic Y2xpZW50JTNBaWQ6c2VjcmV0KyUyQiUyRiUyNg==", handler.Authorization); Assert.DoesNotContain("client_id", handler.Body, StringComparison.Ordinal); Assert.DoesNotContain("client_secret", handler.Body, StringComparison.Ordinal); } finally { secrets["clientSecret"].Value.Dispose(); } } [Fact] public async Task ValidationRejectsMissingDeploymentCallbackBaseUri() { var options = Microsoft.Extensions.Options.Options.Create(new ExternalAuthenticationOptions()); var adapter = new OpenIdConnectExternalAuthenticationAdapter( new ProviderHttpClient(new HttpMessageInvoker(new StaticResponseHandler()), new OutboundDestinationValidator(options, new StaticPublicDnsResolver()), options), new OpenIdConnectSettingsParser(), options); var connection = CreateConnection(CreateManualSettings()); var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, ConnectionScope.Host, ConnectionValidity.Valid, false, "configuration"); var result = await adapter.ValidateAsync(new ConnectionValidationContext(effective, new Dictionary(), new TestSystemClock(DateTimeOffset.UtcNow))); Assert.False(result.IsValid); Assert.Contains(result.Errors, error => error.Field == "redirects.externalCallbackBaseUri"); } [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 AlwaysUsesS256UpstreamPkce() { var request = await CreateAuthorizationRequestAsync(); Assert.Contains("code_challenge_method=S256", request.NavigationUri.Query); Assert.Contains("code_challenge=", request.NavigationUri.Query); } [Theory] [InlineData("https://elsa.example/gateway")] [InlineData("https://elsa.example/gateway/")] public async Task UsesPurposeSpecificCallbacksWithoutDiscardingTheDeploymentBasePath(string callbackBaseUri) { var adapter = CreateAdapter(new StaticResponseHandler(), new Uri(callbackBaseUri)); var connection = CreateConnection(CreateManualSettings()); var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, ConnectionScope.Host, ConnectionValidity.Valid, false, "configuration"); var clock = new TestSystemClock(DateTimeOffset.UtcNow); var signIn = await adapter.CreateAuthorizationRequestAsync(new ExternalAuthorizationContext(effective, new Dictionary(), CreateTransaction(), "sign-in-state", clock)); var previewTransaction = CreateTransaction(); previewTransaction.Purpose = BrokerTransactionPurpose.Preview; var preview = await adapter.CreateAuthorizationRequestAsync(new ExternalAuthorizationContext(effective, new Dictionary(), previewTransaction, "preview-state", clock)); var logout = await adapter.CreateLogoutRequestAsync(new ExternalLogoutContext(effective, new Dictionary(), new BrokerTransaction { Purpose = BrokerTransactionPurpose.UpstreamLogout }, "logout-state", clock)); Assert.Equal("https://elsa.example/gateway/external-authentication/callback/contoso", ParseQuery(signIn.NavigationUri)["redirect_uri"]); Assert.Equal("https://elsa.example/gateway/external-authentication/previews/callback/connection", ParseQuery(preview.NavigationUri)["redirect_uri"]); Assert.Equal("https://elsa.example/gateway/external-authentication/logout/callback/contoso", ParseQuery(Assert.IsType(logout).NavigationUri)["post_logout_redirect_uri"]); } [Fact] public async Task UsesThePreviewCallbackAgainForTheTokenExchange() { var handler = new CapturingTokenResponseHandler(CreateToken()); var adapter = CreateAdapter(handler); var connection = CreateConnection(CreateManualSettings()); var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, ConnectionScope.Host, ConnectionValidity.Valid, false, "configuration"); var transaction = CreateTransaction(); transaction.Purpose = BrokerTransactionPurpose.Preview; var authorization = await adapter.CreateAuthorizationRequestAsync(new ExternalAuthorizationContext(effective, new Dictionary(), transaction, "provider-state", new TestSystemClock(DateTimeOffset.UtcNow))); transaction.ProtectedPayload = authorization.ProtectedAdapterState; var secret = new SensitiveString("secret"); try { await adapter.AuthenticateCallbackAsync(new ExternalCallbackContext(effective, new Dictionary { ["clientSecret"] = new(secret, "generation") }, transaction, "provider-state", new Dictionary> { ["state"] = ["provider-state"], ["code"] = ["provider-code"] }, new TestSystemClock(DateTimeOffset.UtcNow))); Assert.Contains("redirect_uri=https%3A%2F%2Felsa.example%2Fexternal-authentication%2Fpreviews%2Fcallback%2Fconnection", handler.Body, StringComparison.Ordinal); } finally { secret.Dispose(); } } [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","discoveryUrl":"https://issuer.example/.well-known/openid-configuration","clientId":"elsa","clientAuthenticationMethod":"client_secret_basic"}""")); 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, Uri? externalCallbackBaseUri = null) { var options = Microsoft.Extensions.Options.Options.Create(new ExternalAuthenticationOptions { Redirects = new RedirectValidationOptions { ExternalCallbackBaseUri = externalCallbackBaseUri ?? new Uri("https://elsa.example/") } }); var validator = new OutboundDestinationValidator(options, new StaticPublicDnsResolver()); return new OpenIdConnectExternalAuthenticationAdapter(new ProviderHttpClient(new HttpMessageInvoker(handler), validator, options), new OpenIdConnectSettingsParser(), options); } 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"]; var secret = new SensitiveString("test-secret"); try { var secrets = new Dictionary { ["clientSecret"] = new(secret, "generation") }; return await adapter.AuthenticateCallbackAsync(new ExternalCallbackContext(effective, secrets, CreateTransaction(), "provider-state", parameters, new TestSystemClock(DateTimeOffset.UtcNow))); } finally { secret.Dispose(); } } private static async Task CreateAuthorizationRequestAsync() { var connection = CreateConnection(CreateManualSettings()); 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 = 2; 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() => JsonSerializer.SerializeToElement(new { mode = "manual", issuer = "https://issuer.example", authorizationEndpoint = "https://issuer.example/authorize", tokenEndpoint = "https://issuer.example/token", endSessionEndpoint = "https://issuer.example/logout", clientId = "elsa", clientAuthenticationMethod = "client_secret_basic", signingKeys = new { keys = new[] { new { kty = "oct", kid = "test-key", k = Base64UrlEncoder.Encode(SigningKey.Key) } } } }); 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 CapturingTokenResponseHandler(string token) : HttpMessageHandler { public string? Authorization { get; private set; } public string Body { get; private set; } = string.Empty; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Authorization = request.Headers.Authorization?.ToString(); Body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); return new HttpResponseMessage(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); } }