diff --git a/docs/security.md b/docs/security.md index da11d6f2af..e6d313d822 100644 --- a/docs/security.md +++ b/docs/security.md @@ -572,12 +572,69 @@ You can, however, disable TLS for both internal and external TCP. ## Authentication -EventStoreDB supports authentication based on usernames and passwords out of the box. The Enterprise version -also supports LDAP as the authentication source. +EventStoreDB supports authentication based on usernames and passwords out of the box. Nodes can also enable +OAuth bearer-token authentication as a built-in authentication method. This follows PostgreSQL's model of +making the database authentication method explicit, so password and OAuth access can be reasoned about side by side. Authentication is applied to all HTTP endpoints by default, except `/-/liveness`, `/-/readiness`, static web content, and redirects. +### Authentication methods + +Use `Auth:Methods` to choose the authentication methods enabled by the node. If `Auth:Methods` is not set, the +legacy `Auth:AuthenticationType` setting is still honored for compatibility. + +| Format | Syntax | +|:---------------------|:----------------------------| +| Command line | `--auth:methods:0 password --auth:methods:1 oauth` | +| YAML | `Auth:Methods` | +| Environment variable | `EVENTSTORE__AUTH__METHODS__0`, `EVENTSTORE__AUTH__METHODS__1`, ... | + +Supported built-in values are: + +- `password` enables username and password authentication against the node user store. +- `oauth` enables bearer-token authentication against the configured OAuth or OIDC issuer. + +For example, to enable both methods: + +```yaml +Auth: + Methods: + - password + - oauth +``` + +### OAuth authentication + +OAuth authentication validates bearer tokens issued by the configured OAuth or OIDC identity provider. The node +requires an issuer and at least one accepted audience. + +```yaml +Auth: + Methods: + - password + - oauth + OAuth: + Issuer: https://identity.example.com + Audiences: + - eventstore +``` + +The browser sign-in flow is available when the OAuth authorization endpoint, token endpoint, client id, and scopes +are configured. The flow stores only access tokens that the node can validate as JWT bearer tokens. + +```yaml +Auth: + OAuth: + AuthorizationEndpoint: https://identity.example.com/oauth2/authorize + TokenEndpoint: https://identity.example.com/oauth2/token + ClientId: eventstore-ui + Scopes: + - openid + - profile + - email +``` + ### Default users EventStoreDB provides two default users, `$ops` and `$admin`. diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7cd5329613..02aeffccdd 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -50,6 +50,8 @@ + + diff --git a/src/EventStore.ClusterNode/ClusterVNodeHostedService.cs b/src/EventStore.ClusterNode/ClusterVNodeHostedService.cs index 3ef4f7dc1d..777a8d5877 100644 --- a/src/EventStore.ClusterNode/ClusterVNodeHostedService.cs +++ b/src/EventStore.ClusterNode/ClusterVNodeHostedService.cs @@ -14,6 +14,7 @@ using EventStore.Core; using EventStore.Core.Authentication; using EventStore.Core.Authentication.InternalAuthentication; +using EventStore.Core.Authentication.OAuth; using EventStore.Core.Authentication.PassthroughAuthentication; using EventStore.Core.Authorization; using EventStore.Core.Certificates; @@ -246,10 +247,14 @@ AuthenticationProviderFactory GetAuthenticationProviderFactory() return new AuthenticationProviderFactory(_ => new PassthroughAuthenticationProviderFactory()); } - var authenticationTypeToPlugin = new Dictionary { + var authenticationMethodFactories = new Dictionary { { - "internal", new AuthenticationProviderFactory(components => + AuthenticationMethodNames.Password, new AuthenticationProviderFactory(components => new InternalAuthenticationProviderFactory(components, _options.DefaultUser)) + }, + { + AuthenticationMethodNames.OAuth, new AuthenticationProviderFactory(_ => + new OAuthAuthenticationProviderFactory(_options.Auth.OAuth)) } }; @@ -261,9 +266,16 @@ AuthenticationProviderFactory GetAuthenticationProviderFactory() Log.Information( "Loaded authentication plugin: {plugin} version {version} (Command Line: {commandLine})", potentialPlugin.Name, potentialPlugin.Version, commandLine); - authenticationTypeToPlugin.Add(commandLine, + if (!authenticationMethodFactories.TryAdd(commandLine, new AuthenticationProviderFactory(_ => - potentialPlugin.GetAuthenticationProviderFactory(authenticationConfig))); + potentialPlugin.GetAuthenticationProviderFactory(authenticationConfig)))) + { + throw new ApplicationInitializationException( + $"The authentication plugin {potentialPlugin.Name} uses command-line name {commandLine}, " + + "which conflicts with an existing authentication method." + + Environment.NewLine + + $"Valid options for authentication are: {string.Join(", ", authenticationMethodFactories.Keys)}."); + } } catch (CompositionException ex) { @@ -271,14 +283,32 @@ AuthenticationProviderFactory GetAuthenticationProviderFactory() } } - return authenticationTypeToPlugin.TryGetValue(_options.Auth.AuthenticationType.ToLowerInvariant(), - out var factory) - ? factory - : throw new ApplicationInitializationException( - $"The authentication type {_options.Auth.AuthenticationType} is not recognised. If this is supposed " + - $"to be provided by an authentication plugin, confirm the plugin DLL is located in {Locations.PluginsDirectory}." + - Environment.NewLine + - $"Valid options for authentication are: {string.Join(", ", authenticationTypeToPlugin.Keys)}."); + var methods = AuthenticationMethodNames.FromOptions(_options.Auth); + foreach (var method in methods) + { + if (!authenticationMethodFactories.ContainsKey(method)) + { + throw new ApplicationInitializationException( + $"The authentication method {method} is not recognised. If this is supposed " + + $"to be provided by an authentication plugin, confirm the plugin DLL is located in {Locations.PluginsDirectory}." + + Environment.NewLine + + $"Valid options for authentication are: {string.Join(", ", authenticationMethodFactories.Keys)}."); + } + } + + return new AuthenticationProviderFactory(components => + { + var factories = methods.Select(method => + authenticationMethodFactories[method].GetFactory(components)).ToList(); + if (methods.Contains(AuthenticationMethodNames.OAuth, StringComparer.OrdinalIgnoreCase) && + !methods.Contains(AuthenticationMethodNames.Password, StringComparer.OrdinalIgnoreCase)) + { + factories.Add(new UserCertificateAuthenticationProviderFactory( + authenticationMethodFactories[AuthenticationMethodNames.Password].GetFactory(components))); + } + + return new CompositeAuthenticationProviderFactory(factories); + }); } static ClusterVNodeOptions LoadSubsystemsPlugins(PluginLoader pluginLoader, ClusterVNodeOptions options) diff --git a/src/EventStore.ClusterNode/Components/Pages/SignIn.razor b/src/EventStore.ClusterNode/Components/Pages/SignIn.razor index e52abc3ac7..0fde6b1b1f 100644 --- a/src/EventStore.ClusterNode/Components/Pages/SignIn.razor +++ b/src/EventStore.ClusterNode/Components/Pages/SignIn.razor @@ -37,16 +37,34 @@

This node is running with insecure access, so there is no credential exchange to perform.

Continue to UI } - else if (!Authentication.SupportsBasic) + else if (!Authentication.SupportsBasic && !Authentication.SupportsOAuthBrowserFlow) { -

Continue with the configured provider.

-

This node does not advertise Basic authentication, so sign-in is delegated to the configured browser authentication flow.

- - +

Sign-in needs more configuration.

+

This node does not advertise Basic authentication or a browser OAuth flow.

+ @if (!string.IsNullOrWhiteSpace(Message)) + { +

@Message

+ } } else { + @if (Authentication.SupportsOAuthBrowserFlow) + { +
+

Continue with OAuth

+

Use the configured browser sign-in flow for this node.

+ + @if (!string.IsNullOrWhiteSpace(Message)) + { +

@Message

+ } + +
+ } + + @if (Authentication.SupportsBasic) + { @@ -74,6 +92,7 @@ Back to UI + } } @@ -82,12 +101,16 @@ [SupplyParameterFromQuery(Name = "returnUrl")] private string ReturnUrl { get; set; } = ""; + [SupplyParameterFromQuery(Name = "oauth_error")] + private string OAuthError { get; set; } = ""; + [SupplyParameterFromForm(FormName = "ui-signin")] private SignInInput Input { get; set; } private SecurityAuthenticationInfo Authentication { get; set; } = SecurityAuthenticationInfo.Unavailable(); private bool AuthenticationAvailable { get; set; } = true; private string Message { get; set; } = ""; + private string DisplayedOAuthError { get; set; } = ""; private string Destination => SecurityBrowserService.NormalizeReturnUrl(ReturnUrl); protected override void OnParametersSet() { @@ -96,9 +119,19 @@ try { Authentication = Security.AuthenticationInfo(); AuthenticationAvailable = true; + if (string.IsNullOrWhiteSpace(OAuthError)) { + if (!string.IsNullOrWhiteSpace(DisplayedOAuthError)) { + Message = ""; + DisplayedOAuthError = ""; + } + } else { + Message = OAuthErrorMessage(OAuthError); + DisplayedOAuthError = OAuthError; + } } catch (Exception ex) { Authentication = SecurityAuthenticationInfo.Unavailable(); AuthenticationAvailable = false; + DisplayedOAuthError = ""; Message = $"Unable to load authentication information: {UserInterfaceMessages.Friendly(ex)}"; } @@ -114,16 +147,29 @@ request.Query.ContainsKey("state") || request.Query.ContainsKey("error"); + private static string OAuthErrorMessage(string error) => + error switch + { + "invalid_state" => "The OAuth sign-in session expired or could not be verified. Try again.", + "missing_callback" => "The identity provider did not return the expected OAuth callback data.", + "missing_token" => "The identity provider did not return an OAuth access token.", + "unsupported_token" => "The identity provider did not return a token this node can validate.", + "provider_error" => "The identity provider rejected the OAuth sign-in request.", + _ => "The OAuth sign-in flow could not be completed." + }; + private async Task SignInUser() { try { var result = await Security.Validate(Input.Username, Input.Password); if (!result.Success) { + DisplayedOAuthError = ""; Message = result.Message; return; } var context = HttpContextAccessor.HttpContext; if (context is null) { + DisplayedOAuthError = ""; Message = "The sign-in response is no longer available."; return; } @@ -131,9 +177,11 @@ UiCredentialCookie.DeleteOAuthToken(context.Response); UiCredentialCookie.AppendBasic(context.Response, new UiCredentials(Input.Username.Trim(), Input.Password)); } catch (OperationCanceledException) { + DisplayedOAuthError = ""; Message = "Sign-in was canceled."; return; } catch (Exception ex) { + DisplayedOAuthError = ""; Message = $"Failed to sign in: {UserInterfaceMessages.Friendly(ex)}"; return; } diff --git a/src/EventStore.ClusterNode/Components/Services/ConfigurationBrowserService.cs b/src/EventStore.ClusterNode/Components/Services/ConfigurationBrowserService.cs index 6760489a0a..566edfbcc5 100644 --- a/src/EventStore.ClusterNode/Components/Services/ConfigurationBrowserService.cs +++ b/src/EventStore.ClusterNode/Components/Services/ConfigurationBrowserService.cs @@ -5,6 +5,7 @@ using EventStore.Common.Options; using EventStore.Common.Utils; using EventStore.Core; +using EventStore.Core.Authentication; using EventStore.Core.Data; using EventStore.Core.Services; using EventStore.Core.Util; @@ -22,7 +23,7 @@ public ConfigurationPage Read() var features = new[] { new ConfigurationFeature("Projections", options.Projection.RunProjections != ProjectionType.None || options.DevMode.Dev), new ConfigurationFeature("User management", - options.Auth.AuthenticationType == Opts.AuthenticationTypeDefault && !options.Application.AuthDisabled()) + AuthenticationMethodNames.IncludesBuiltInUserStore(options.Auth) && !options.Application.AuthDisabled()) }; var subsystems = hostedService.EnabledNodeSubsystems @@ -44,7 +45,7 @@ public ConfigurationPage Read() canInspectRuntimeDetails ? new IPEndPoint(options.Interface.NodeIp, options.Interface.NodePort).ToString() : "", canInspectRuntimeDetails ? options.Database.Db : "", canInspectRuntimeDetails ? options.Database.DbLogFormat.ToString() : "", - canInspectRuntimeDetails ? options.Auth.AuthenticationType : "", + canInspectRuntimeDetails ? string.Join(", ", AuthenticationMethodNames.FromOptions(options.Auth)) : "", canInspectRuntimeDetails ? options.Auth.AuthorizationType : "", options.Application.Insecure, options.Application.TlsDisabled(), diff --git a/src/EventStore.ClusterNode/Components/Services/OAuthBrowserFlowEndpoints.cs b/src/EventStore.ClusterNode/Components/Services/OAuthBrowserFlowEndpoints.cs new file mode 100644 index 0000000000..aa990d6630 --- /dev/null +++ b/src/EventStore.ClusterNode/Components/Services/OAuthBrowserFlowEndpoints.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Core; +using EventStore.Core.Authentication.OAuth; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace EventStore.ClusterNode.Components.Services; + +internal static class OAuthBrowserFlowEndpoints +{ + public static IEndpointRouteBuilder MapOAuthBrowserFlowEndpoints( + this IEndpointRouteBuilder app, + ClusterVNodeOptions.OAuthOptions options) + { + app.MapGet(options.CodeChallengePath, (HttpContext context, OAuthBrowserFlowService service) => + Results.Json(service.CreateCodeChallenge(context), OAuthBrowserFlowService.JsonOptions)); + + app.MapGet(options.RedirectPath, async ( + HttpContext context, + OAuthBrowserFlowService service) => + await service.HandleCallback(context, context.RequestAborted)); + + return app; + } +} + +public sealed class OAuthBrowserFlowService( + ClusterVNodeOptions.OAuthOptions options, + HttpClient httpClient, + TimeProvider timeProvider, + IDataProtectionProvider dataProtectionProvider, + OAuthTokenValidator tokenValidator, + bool adminUiEnabled) : IDisposable +{ + public static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private const string ChallengeCookieName = "eventstore-ui-oauth-pkce"; + private static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(5); + private readonly IDataProtector _challengeProtector = dataProtectionProvider.CreateProtector("EventStore.ClusterNode.Components.Services.OAuthBrowserFlowService.Pkce"); + + public OAuthCodeChallenge CreateCodeChallenge(HttpContext context) + { + var verifier = Base64Url(RandomNumberGenerator.GetBytes(32)); + var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + var correlationId = Base64Url(RandomNumberGenerator.GetBytes(32)); + var payload = new OAuthChallengeCookie(verifier, correlationId, timeProvider.GetUtcNow().Add(ChallengeLifetime)); + + context.Response.Cookies.Append( + ChallengeCookieName, + _challengeProtector.Protect(JsonSerializer.Serialize(payload, JsonOptions)), + ChallengeCookieOptions(context.Request)); + return new OAuthCodeChallenge(correlationId, challenge, "S256"); + } + + public async Task HandleCallback(HttpContext context, CancellationToken cancellationToken) + { + var code = context.Request.Query["code"].ToString(); + var state = context.Request.Query["state"].ToString(); + var providerError = context.Request.Query["error"].ToString(); + var hasState = TryReadState(state, out var correlationId, out var returnUrl, out var redirectUri); + if (!string.IsNullOrWhiteSpace(providerError)) + { + DeleteChallengeCookie(context); + return ErrorRedirect("provider_error", hasState ? returnUrl : ""); + } + + if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state)) + { + DeleteChallengeCookie(context); + return ErrorRedirect("missing_callback", ""); + } + + if (!hasState) + { + DeleteChallengeCookie(context); + return ErrorRedirect("invalid_state", ""); + } + + if (!TryReadChallenge(context.Request, correlationId, out var challenge) || + challenge.ExpiresAt <= timeProvider.GetUtcNow()) + { + DeleteChallengeCookie(context); + return ErrorRedirect("invalid_state", returnUrl); + } + + DeleteChallengeCookie(context); + var token = await ExchangeCode( + code, + challenge.CodeVerifier, + RedirectUri(context.Request, redirectUri), + cancellationToken); + if (string.IsNullOrWhiteSpace(token)) + { + return ErrorRedirect("missing_token", returnUrl); + } + + if (!LooksLikeJwt(token)) + { + return ErrorRedirect("unsupported_token", returnUrl); + } + + var validationResult = await tokenValidator.ValidateTokenAsync(token, cancellationToken); + if (!validationResult.IsValid) + { + return ErrorRedirect("invalid_token", returnUrl); + } + + UiCredentialCookie.Delete(context.Response); + UiCredentialCookie.AppendOAuthToken(context.Response, token); + return Results.Redirect(adminUiEnabled ? SignInLocation(returnUrl) : DirectReturnLocation(returnUrl)); + } + + private async Task ExchangeCode( + string code, + string codeVerifier, + string baseUri, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(options.ClientId)) + { + return ""; + } + + if (string.IsNullOrWhiteSpace(options.TokenEndpoint)) + { + return ""; + } + + var values = new Dictionary + { + ["grant_type"] = "authorization_code", + ["client_id"] = options.ClientId, + ["code"] = code, + ["redirect_uri"] = baseUri, + ["code_verifier"] = codeVerifier + }; + + if (!string.IsNullOrWhiteSpace(options.ClientSecret)) + { + values["client_secret"] = options.ClientSecret; + } + + using var response = await httpClient.PostAsync( + TokenEndpoint(options), + new FormUrlEncodedContent(values), + cancellationToken); + if (!response.IsSuccessStatusCode) + { + return ""; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var tokenResponse = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken); + return tokenResponse?.AccessToken ?? ""; + } + + private static bool LooksLikeJwt(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + var segments = token.Split('.'); + return segments.Length is 3 or 5 && segments.All(segment => !string.IsNullOrWhiteSpace(segment)); + } + + private bool TryReadChallenge(HttpRequest request, string correlationId, out OAuthChallengeCookie challenge) + { + challenge = new OAuthChallengeCookie("", "", DateTimeOffset.MinValue); + try + { + if (!request.Cookies.TryGetValue(ChallengeCookieName, out var value)) + { + return false; + } + + var json = _challengeProtector.Unprotect(value); + challenge = JsonSerializer.Deserialize(json, JsonOptions) ?? challenge; + return !string.IsNullOrWhiteSpace(challenge.CodeVerifier) && + string.Equals(challenge.CorrelationId, correlationId, StringComparison.Ordinal); + } + catch (Exception ex) when (ex is CryptographicException or JsonException) + { + return false; + } + } + + private IResult ErrorRedirect(string error, string returnUrl) + { + var location = adminUiEnabled ? SignInLocation(returnUrl) : DirectReturnLocation(returnUrl); + return Results.Redirect($"{location}{(location.Contains('?') ? '&' : '?')}oauth_error={Uri.EscapeDataString(error)}"); + } + + private bool TryReadState(string state, out string correlationId, out string returnUrl, out string redirectUri) + { + correlationId = ""; + returnUrl = ""; + redirectUri = ""; + try + { + var json = Encoding.UTF8.GetString(Convert.FromBase64String(state)); + using var document = JsonDocument.Parse(json); + if (!document.RootElement.TryGetProperty("code_challenge_correlation_id", out var element)) + { + return false; + } + + correlationId = element.GetString() ?? ""; + if (document.RootElement.TryGetProperty("return_url", out var returnUrlElement)) + { + returnUrl = SecurityBrowserService.NormalizeReturnUrl(returnUrlElement.GetString() ?? ""); + } + + if (document.RootElement.TryGetProperty("redirect_uri", out var redirectUriElement)) + { + redirectUri = NormalizeRedirectUri(redirectUriElement.GetString() ?? ""); + } + + return !string.IsNullOrWhiteSpace(correlationId); + } + catch (Exception ex) when (ex is FormatException or JsonException) + { + return false; + } + } + + private string RedirectUri(HttpRequest request, string redirectUri) => + string.IsNullOrWhiteSpace(redirectUri) + ? $"{request.Scheme}://{request.Host}{options.RedirectPath}" + : redirectUri; + + private string NormalizeRedirectUri(string redirectUri) + { + if (!Uri.TryCreate(redirectUri, UriKind.Absolute, out var uri)) + { + return ""; + } + + if (!uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + !uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) + { + return ""; + } + + return uri.AbsolutePath.Equals(options.RedirectPath, StringComparison.Ordinal) + ? uri.GetLeftPart(UriPartial.Path) + : ""; + } + + private static string SignInLocation(string returnUrl) => + string.IsNullOrWhiteSpace(returnUrl) || returnUrl == "/ui" + ? "/ui/signin" + : $"/ui/signin?returnUrl={Uri.EscapeDataString(returnUrl)}"; + + private static string DirectReturnLocation(string returnUrl) => + string.IsNullOrWhiteSpace(returnUrl) || + returnUrl.Equals("/ui", StringComparison.OrdinalIgnoreCase) || + returnUrl.StartsWith("/ui/", StringComparison.OrdinalIgnoreCase) + ? "/" + : returnUrl; + + private void DeleteChallengeCookie(HttpContext context) => + context.Response.Cookies.Delete(ChallengeCookieName, ChallengeCookieOptions(context.Request, maxAge: null)); + + private CookieOptions ChallengeCookieOptions(HttpRequest request) => + ChallengeCookieOptions(request, ChallengeLifetime); + + private CookieOptions ChallengeCookieOptions(HttpRequest request, TimeSpan? maxAge) => new() + { + HttpOnly = true, + Secure = request.IsHttps, + SameSite = SameSiteMode.Lax, + Path = "/", + MaxAge = maxAge + }; + + private static string TokenEndpoint(ClusterVNodeOptions.OAuthOptions options) => + options.TokenEndpoint; + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + public void Dispose() => + httpClient.Dispose(); + + private sealed record OAuthChallengeCookie(string CodeVerifier, string CorrelationId, DateTimeOffset ExpiresAt); +} + +public sealed record OAuthCodeChallenge( + [property: JsonPropertyName("code_challenge_correlation_id")] + string CodeChallengeCorrelationId, + [property: JsonPropertyName("code_challenge")] + string CodeChallenge, + [property: JsonPropertyName("code_challenge_method")] + string CodeChallengeMethod); + +public sealed record OAuthTokenResponse([property: JsonPropertyName("access_token")] string AccessToken); diff --git a/src/EventStore.ClusterNode/Components/Services/SecurityBrowserService.cs b/src/EventStore.ClusterNode/Components/Services/SecurityBrowserService.cs index 0071043f45..1e4e6b7f9c 100644 --- a/src/EventStore.ClusterNode/Components/Services/SecurityBrowserService.cs +++ b/src/EventStore.ClusterNode/Components/Services/SecurityBrowserService.cs @@ -9,7 +9,7 @@ namespace EventStore.ClusterNode.Components.Services; -public sealed class SecurityBrowserService(IAuthenticationProvider authenticationProvider) +public sealed class SecurityBrowserService(IAuthenticationProvider authenticationProvider, bool supportsPassword) { private static readonly Uri LocalBaseUri = new("http://localhost", UriKind.Absolute); @@ -24,7 +24,15 @@ public SecurityAuthenticationInfo AuthenticationInfo() return new SecurityAuthenticationInfo( authenticationProvider.Name, - schemes.Any(x => string.Equals(x, "Basic", StringComparison.OrdinalIgnoreCase)), + supportsPassword, + schemes.Any(x => string.Equals(x, "Bearer", StringComparison.OrdinalIgnoreCase)) && + properties.ContainsKey("authorization_endpoint") && + properties.ContainsKey("client_id") && + properties.ContainsKey("code_challenge_uri") && + properties.ContainsKey("redirect_uri") && + properties.ContainsKey("response_type") && + properties.TryGetValue("scope", out var scope) && + !string.IsNullOrWhiteSpace(scope), schemes.Any(x => string.Equals(x, "Insecure", StringComparison.OrdinalIgnoreCase)), properties); } @@ -87,11 +95,12 @@ private static bool IsUiPath(string path) => public sealed record SecurityAuthenticationInfo( string Type, bool SupportsBasic, + bool SupportsOAuthBrowserFlow, bool IsInsecure, IReadOnlyDictionary Properties) { public static SecurityAuthenticationInfo Unavailable() => - new("Unavailable", SupportsBasic: false, IsInsecure: false, new Dictionary()); + new("Unavailable", SupportsBasic: false, SupportsOAuthBrowserFlow: false, IsInsecure: false, new Dictionary()); public string TypeLabel => string.IsNullOrWhiteSpace(Type) ? "External authentication" : Type; public string PropertiesJson => JsonSerializer.Serialize(Properties); diff --git a/src/EventStore.ClusterNode/Components/Services/UiCredentialCookie.cs b/src/EventStore.ClusterNode/Components/Services/UiCredentialCookie.cs index dac1f2749a..2edcc93b8a 100644 --- a/src/EventStore.ClusterNode/Components/Services/UiCredentialCookie.cs +++ b/src/EventStore.ClusterNode/Components/Services/UiCredentialCookie.cs @@ -14,11 +14,17 @@ public sealed record UiCredentials(string Username, string Password) public static class UiCredentialCookie { public const string BasicCookieName = "es-creds"; - public const string OAuthCookieName = "oauth_id_token"; + public const string OAuthCookieName = "oauth_token"; public static void AppendBasic(HttpResponse response, UiCredentials credentials) => AppendBasicValue(response, credentials.BasicValue); + public static void AppendOAuthToken(HttpResponse response, string token) => + response.Cookies.Append( + OAuthCookieName, + token, + Options(response.HttpContext.Request.IsHttps)); + private static void AppendBasicValue(HttpResponse response, string value) => response.Cookies.Append( BasicCookieName, diff --git a/src/EventStore.ClusterNode/Program.cs b/src/EventStore.ClusterNode/Program.cs index 3ed49fe05d..a216b359f0 100644 --- a/src/EventStore.ClusterNode/Program.cs +++ b/src/EventStore.ClusterNode/Program.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; using System.Net.Security; using System.Runtime; using System.Security.Authentication; @@ -15,10 +16,14 @@ using EventStore.Common.Log; using EventStore.Common.Utils; using EventStore.Core; +using EventStore.Core.Authentication; +using EventStore.Core.Authentication.OAuth; using EventStore.Core.Certificates; using EventStore.Core.Configuration; using EventStore.Core.Services.Transport.Http; +using EventStore.Plugins.Authentication; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; @@ -282,6 +287,7 @@ async Task Run(ClusterVNodeHostedService hostedService, ManualResetEventSlim sig hostedService.Node.Startup.ConfigureServicesOnly(builder.Services); builder.Services.AddHttpContextAccessor(); + builder.Services.AddDataProtection(); builder.Services.AddRazorComponents(); builder.Services.AddScoped(); builder.Services.AddSingleton(); @@ -290,15 +296,30 @@ async Task Run(ClusterVNodeHostedService hostedService, ManualResetEventSlim sig builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(serviceProvider => new SecurityBrowserService( + serviceProvider.GetRequiredService(), + AuthenticationMethodNames.IncludesPassword(options.Auth))); builder.Services.AddScoped(); builder.Services.AddScoped(); + var oauthEnabled = AuthenticationMethodNames.IncludesOAuth(options.Auth); + var adminUiEnabled = !options.Interface.DisableAdminUi; + if (oauthEnabled) + { + var oauthHttpHandler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(15) }; + builder.Services.AddSingleton(serviceProvider => + new OAuthBrowserFlowService( + options.Auth.OAuth, + new HttpClient(oauthHttpHandler), + TimeProvider.System, + serviceProvider.GetRequiredService(), + new OAuthTokenValidator(options.Auth.OAuth), + adminUiEnabled)); + } builder.Services.AddSingleton(hostedService); builder.Services.AddSingleton(hostedService); var app = builder.Build(); app.UseMiddleware(); - var adminUiEnabled = !options.Interface.DisableAdminUi; if (adminUiEnabled && Directory.Exists(Locations.UiAssetsDirectory)) { app.UseStaticFiles(new StaticFileOptions @@ -312,6 +333,11 @@ async Task Run(ClusterVNodeHostedService hostedService, ManualResetEventSlim sig Log.Warning("UI assets directory {UiAssetsDirectory} is not available.", Locations.UiAssetsDirectory); } hostedService.Node.Startup.Configure(app); + if (oauthEnabled) + { + app.MapOAuthBrowserFlowEndpoints(options.Auth.OAuth); + } + if (adminUiEnabled) { app.MapAdminOperationsEndpoints(); diff --git a/src/EventStore.ClusterNode/ui-assets/js/ui-auth.js b/src/EventStore.ClusterNode/ui-assets/js/ui-auth.js index 60f9cc7568..bcf21f35a9 100644 --- a/src/EventStore.ClusterNode/ui-assets/js/ui-auth.js +++ b/src/EventStore.ClusterNode/ui-assets/js/ui-auth.js @@ -30,7 +30,7 @@ } function readAuthorization() { - var token = safeDecode(readCookie("oauth_id_token")); + var token = safeDecode(readCookie("oauth_token")); if (isHeaderSafe(token)) return "Bearer " + token; @@ -81,7 +81,7 @@ function clearReadableAuthCookies() { clearCookie("es-creds"); - clearCookie("oauth_id_token"); + clearCookie("oauth_token"); } function setStatus(message) { @@ -103,10 +103,14 @@ async function beginOAuthSignIn(button) { try { - var providerType = (button.getAttribute("data-ui-oauth-type") || "").toLowerCase(); var properties = readJsonAttribute(button, "data-ui-oauth-properties"); - if (providerType !== "oauth") - throw new Error("The configured provider is not an OAuth browser flow."); + if (!properties.authorization_endpoint || + !properties.client_id || + !properties.code_challenge_uri || + !properties.redirect_uri || + !properties.response_type || + !properties.scope) + throw new Error("The configured provider does not advertise an OAuth browser flow."); var baseUrl = window.location.protocol + "//" + window.location.host; var challengeResponse = await originalFetch(baseUrl + properties.code_challenge_uri); @@ -114,20 +118,22 @@ throw new Error("Code challenge endpoint returned " + challengeResponse.status + " " + challengeResponse.statusText); var challenge = await challengeResponse.json(); + var returnUrl = button.getAttribute("data-ui-oauth-return") || ""; + var redirectUri = baseUrl + properties.redirect_uri; var state = btoa(JSON.stringify({ - code_challenge_correlation_id: challenge.code_challenge_correlation_id + code_challenge_correlation_id: challenge.code_challenge_correlation_id, + return_url: returnUrl, + redirect_uri: redirectUri })); - var redirectUri = encodeURIComponent(baseUrl + properties.redirect_uri); var target = properties.authorization_endpoint + "?response_type=" + encodeURIComponent(properties.response_type) + "&client_id=" + encodeURIComponent(properties.client_id) + - "&redirect_uri=" + redirectUri + + "&redirect_uri=" + encodeURIComponent(redirectUri) + "&scope=" + encodeURIComponent(properties.scope) + "&code_challenge=" + encodeURIComponent(challenge.code_challenge) + "&code_challenge_method=" + encodeURIComponent(challenge.code_challenge_method) + "&state=" + encodeURIComponent(state); - var returnUrl = button.getAttribute("data-ui-oauth-return"); if (returnUrl) sessionStorage.setItem("eventstore-ui-return-url", returnUrl); diff --git a/src/EventStore.Core.Tests/Authentication/CompositeAuthenticationProviderTests.cs b/src/EventStore.Core.Tests/Authentication/CompositeAuthenticationProviderTests.cs new file mode 100644 index 0000000000..cc7d6c7b2b --- /dev/null +++ b/src/EventStore.Core.Tests/Authentication/CompositeAuthenticationProviderTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using EventStore.Core.Authentication; +using EventStore.Plugins.Authentication; +using Microsoft.AspNetCore.Http; +using NUnit.Framework; + +namespace EventStore.Core.Tests.Authentication; + +[TestFixture] +public class CompositeAuthenticationProviderTests +{ + [Test] + public async Task routes_bearer_requests_to_bearer_provider() + { + var basicProvider = new RecordingAuthenticationProvider(["Basic"]); + var bearerProvider = new RecordingAuthenticationProvider(["Bearer"]); + var provider = new CompositeAuthenticationProvider([basicProvider, bearerProvider]); + var request = new HttpAuthenticationRequest(new DefaultHttpContext(), "jwt-token"); + + provider.Authenticate(request); + await request.AuthenticateAsync(); + + Assert.AreEqual(0, basicProvider.Calls); + Assert.AreEqual(1, bearerProvider.Calls); + } + + [Test] + public async Task routes_password_requests_to_basic_provider() + { + var basicProvider = new RecordingAuthenticationProvider(["Basic"]); + var bearerProvider = new RecordingAuthenticationProvider(["Bearer"]); + var provider = new CompositeAuthenticationProvider([basicProvider, bearerProvider]); + var request = new HttpAuthenticationRequest(new DefaultHttpContext(), "admin", "changeit"); + + provider.Authenticate(request); + await request.AuthenticateAsync(); + + Assert.AreEqual(1, basicProvider.Calls); + Assert.AreEqual(0, bearerProvider.Calls); + } + + [Test] + public async Task routes_certificate_requests_to_user_certificate_provider() + { + var bearerProvider = new RecordingAuthenticationProvider(["Bearer"]); + var certificateProvider = new RecordingAuthenticationProvider(["UserCertificate"]); + var provider = new CompositeAuthenticationProvider([bearerProvider, certificateProvider]); + var context = new DefaultHttpContext(); + var request = HttpAuthenticationRequest.CreateWithValidCertificate(context, "admin", CreateCertificate()); + + provider.Authenticate(request); + await request.AuthenticateAsync(); + + Assert.AreEqual(0, bearerProvider.Calls); + Assert.AreEqual(1, certificateProvider.Calls); + } + + [Test] + public async Task does_not_route_password_requests_to_user_certificate_provider() + { + var bearerProvider = new RecordingAuthenticationProvider(["Bearer"]); + var certificateProvider = new RecordingAuthenticationProvider(["UserCertificate"]); + var provider = new CompositeAuthenticationProvider([bearerProvider, certificateProvider]); + var request = new HttpAuthenticationRequest(new DefaultHttpContext(), "admin", "changeit"); + + provider.Authenticate(request); + var status = await request.AuthenticateAsync(); + + Assert.AreEqual(HttpAuthenticationRequestStatus.Unauthenticated, status.Item1); + Assert.AreEqual(0, bearerProvider.Calls); + Assert.AreEqual(0, certificateProvider.Calls); + } + + [Test] + public async Task user_certificate_wrapper_allows_certificate_requests_over_basic_http_path() + { + var innerProvider = new RecordingAuthenticationProvider(["Basic", "UserCertificate"]); + var certificateProvider = new UserCertificateAuthenticationProvider(innerProvider); + var provider = new CompositeAuthenticationProvider([new RecordingAuthenticationProvider(["Bearer"]), certificateProvider]); + var context = new DefaultHttpContext(); + var request = HttpAuthenticationRequest.CreateWithValidCertificate(context, "admin", CreateCertificate()); + + provider.Authenticate(request); + await request.AuthenticateAsync(); + + Assert.AreEqual(1, innerProvider.Calls); + } + + [Test] + public async Task user_certificate_wrapper_rejects_basic_password_requests() + { + var innerProvider = new RecordingAuthenticationProvider(["Basic", "UserCertificate"]); + var certificateProvider = new UserCertificateAuthenticationProvider(innerProvider); + var provider = new CompositeAuthenticationProvider([new RecordingAuthenticationProvider(["Bearer"]), certificateProvider]); + var request = new HttpAuthenticationRequest(new DefaultHttpContext(), "admin", "changeit"); + + provider.Authenticate(request); + var status = await request.AuthenticateAsync(); + + Assert.AreEqual(HttpAuthenticationRequestStatus.Unauthenticated, status.Item1); + Assert.AreEqual(0, innerProvider.Calls); + } + + private static X509Certificate2 CreateCertificate() + { + using var rsa = RSA.Create(); + var request = new CertificateRequest("CN=test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddMinutes(1)); + } + + private sealed class RecordingAuthenticationProvider(IReadOnlyList schemes) : AuthenticationProviderBase + { + public int Calls { get; private set; } + + public override void Authenticate(AuthenticationRequest authenticationRequest) + { + Calls++; + authenticationRequest.Authenticated( + new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "test")], "test"))); + } + + public override IReadOnlyList GetSupportedAuthenticationSchemes() => schemes; + } +} diff --git a/src/EventStore.Core.Tests/Authentication/OAuthAuthenticationProviderTests.cs b/src/EventStore.Core.Tests/Authentication/OAuthAuthenticationProviderTests.cs new file mode 100644 index 0000000000..616cf1c34e --- /dev/null +++ b/src/EventStore.Core.Tests/Authentication/OAuthAuthenticationProviderTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Core.Authentication.OAuth; +using EventStore.Core.Services; +using EventStore.Plugins.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using NUnit.Framework; + +namespace EventStore.Core.Tests.Authentication; + +[TestFixture] +public class OAuthAuthenticationProviderTests +{ + [Test] + public async Task authenticates_valid_bearer_token_and_maps_roles() + { + var signingKey = new SymmetricSecurityKey(Guid.NewGuid().ToByteArray().Concat(Guid.NewGuid().ToByteArray()).ToArray()); + var token = CreateToken(signingKey, audience: "eventstore"); + var provider = new OAuthAuthenticationProvider( + new() + { + Issuer = "https://login.example.test", + Audiences = ["eventstore"], + NameClaimType = "sub", + RoleClaimType = "roles" + }, + logFailedAuthenticationAttempts: false, + _ => new ValueTask(CreateValidationParameters(signingKey))); + + var request = new HttpAuthenticationRequest(new DefaultHttpContext(), token); + provider.Authenticate(request); + + var (status, principal) = await request.AuthenticateAsync(); + + Assert.AreEqual(HttpAuthenticationRequestStatus.Authenticated, status); + Assert.AreEqual("alice", principal.Identity?.Name); + Assert.That(principal.HasClaim(ClaimTypes.Role, SystemRoles.Admins), Is.True); + } + + [Test] + public async Task rejects_token_with_wrong_audience() + { + var signingKey = new SymmetricSecurityKey(Guid.NewGuid().ToByteArray().Concat(Guid.NewGuid().ToByteArray()).ToArray()); + var token = CreateToken(signingKey, audience: "other-service"); + var provider = new OAuthAuthenticationProvider( + new() + { + Issuer = "https://login.example.test", + Audiences = ["eventstore"], + NameClaimType = "sub", + RoleClaimType = "roles" + }, + logFailedAuthenticationAttempts: false, + _ => new ValueTask(CreateValidationParameters(signingKey))); + + var request = new HttpAuthenticationRequest(new DefaultHttpContext(), token); + provider.Authenticate(request); + + var (status, _) = await request.AuthenticateAsync(); + + Assert.AreEqual(HttpAuthenticationRequestStatus.Unauthenticated, status); + } + + [Test] + public void does_not_advertise_browser_flow_without_client_id() + { + var provider = new OAuthAuthenticationProvider( + new() + { + Issuer = "https://login.example.test", + Audiences = ["eventstore"] + }, + logFailedAuthenticationAttempts: false, + _ => new ValueTask(CreateValidationParameters(new SymmetricSecurityKey(new byte[32])))); + + var properties = provider.GetPublicProperties().ToDictionary(x => x.Key, x => x.Value); + + Assert.That(properties.ContainsKey("authorization_endpoint"), Is.False); + Assert.That(properties.ContainsKey("client_id"), Is.False); + } + + [Test] + public void does_not_advertise_browser_flow_without_scopes() + { + var provider = new OAuthAuthenticationProvider( + new() + { + Issuer = "https://login.example.test", + Audiences = ["eventstore"], + AuthorizationEndpoint = "https://login.example.test/oauth2/auth", + TokenEndpoint = "https://login.example.test/oauth2/token", + ClientId = "eventstore-ui", + Scopes = [] + }, + logFailedAuthenticationAttempts: false, + _ => new ValueTask(CreateValidationParameters(new SymmetricSecurityKey(new byte[32])))); + + var properties = provider.GetPublicProperties().ToDictionary(x => x.Key, x => x.Value); + + Assert.That(properties.ContainsKey("authorization_endpoint"), Is.False); + Assert.That(properties.ContainsKey("scope"), Is.False); + } + + [Test] + public void advertises_configured_browser_flow_properties() + { + var provider = new OAuthAuthenticationProvider( + new() + { + Issuer = "https://login.example.test", + Audiences = ["eventstore"], + AuthorizationEndpoint = "https://login.example.test/oauth2/auth", + TokenEndpoint = "https://login.example.test/oauth2/token", + ClientId = "eventstore-ui", + Scopes = ["openid", "profile", "roles"], + CodeChallengePath = "/custom/challenge", + RedirectPath = "/custom/callback" + }, + logFailedAuthenticationAttempts: false, + _ => new ValueTask(CreateValidationParameters(new SymmetricSecurityKey(new byte[32])))); + + var properties = provider.GetPublicProperties().ToDictionary(x => x.Key, x => x.Value); + + Assert.AreEqual("https://login.example.test/oauth2/auth", properties["authorization_endpoint"]); + Assert.AreEqual("eventstore-ui", properties["client_id"]); + Assert.AreEqual("/custom/challenge", properties["code_challenge_uri"]); + Assert.AreEqual("/custom/callback", properties["redirect_uri"]); + Assert.AreEqual("code", properties["response_type"]); + Assert.AreEqual("openid profile roles", properties["scope"]); + } + + private static string CreateToken(SecurityKey signingKey, string audience) + { + var descriptor = new SecurityTokenDescriptor + { + Issuer = "https://login.example.test", + Audience = audience, + Subject = new ClaimsIdentity([ + new Claim("sub", "alice"), + new Claim("roles", SystemRoles.Admins) + ]), + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256) + }; + + return new JsonWebTokenHandler().CreateToken(descriptor); + } + + private static TokenValidationParameters CreateValidationParameters(SecurityKey signingKey) => + new() + { + ValidateIssuer = true, + ValidIssuer = "https://login.example.test", + ValidateAudience = true, + ValidAudiences = ["eventstore"], + ValidateIssuerSigningKey = true, + IssuerSigningKey = signingKey, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + NameClaimType = "sub", + RoleClaimType = "roles" + }; +} diff --git a/src/EventStore.Core.Tests/Authentication/OAuthBrowserFlowServiceTests.cs b/src/EventStore.Core.Tests/Authentication/OAuthBrowserFlowServiceTests.cs new file mode 100644 index 0000000000..d4605b26f1 --- /dev/null +++ b/src/EventStore.Core.Tests/Authentication/OAuthBrowserFlowServiceTests.cs @@ -0,0 +1,354 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using EventStore.ClusterNode.Components.Services; +using EventStore.Core; +using EventStore.Core.Authentication.OAuth; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using NUnit.Framework; + +namespace EventStore.Core.Tests.Authentication; + +[TestFixture] +public class OAuthBrowserFlowServiceTests +{ + private static readonly SymmetricSecurityKey SigningKey = + new(Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").ToByteArray().Concat( + Guid.Parse("11111111-2222-3333-4444-555555555555").ToByteArray()).ToArray()); + private static readonly string JwtToken = CreateToken(audience: "eventstore"); + + [Test] + public void creates_code_challenge_using_browser_contract_names() + { + var service = Service(new TokenHandler()); + var context = HttpsContext(); + + var challenge = service.CreateCodeChallenge(context); + var json = JsonSerializer.Serialize(challenge, OAuthBrowserFlowService.JsonOptions); + + Assert.That(json, Does.Contain("code_challenge_correlation_id")); + Assert.That(json, Does.Contain("code_challenge")); + Assert.That(json, Does.Contain("code_challenge_method")); + Assert.AreEqual("S256", challenge.CodeChallengeMethod); + Assert.That(challenge.CodeChallenge, Is.Not.Empty); + Assert.That(challenge.CodeChallengeCorrelationId, Is.Not.Empty); + Assert.That(context.Response.Headers.SetCookie.ToString(), Does.Contain("eventstore-ui-oauth-pkce=")); + } + + [Test] + public async Task callback_exchanges_code_and_sets_oauth_token_cookie() + { + var handler = new TokenHandler(); + var service = Service(handler); + var challengeContext = HttpsContext(); + var challenge = service.CreateCodeChallenge(challengeContext); + var context = HttpsContext(); + context.Request.Headers.Cookie = challengeContext.Response.Headers.SetCookie.ToString().Split(';')[0]; + context.Request.Query = new QueryCollection(new Dictionary + { + ["code"] = new StringValues("authorization-code"), + ["state"] = new StringValues(State(challenge.CodeChallengeCorrelationId)) + }); + + var result = await service.HandleCallback(context, CancellationToken.None); + await result.ExecuteAsync(context); + + Assert.AreEqual(HttpStatusCode.Redirect, (HttpStatusCode)context.Response.StatusCode); + Assert.That(context.Response.Headers.Location.ToString(), Is.EqualTo("/ui/signin?returnUrl=%2Fui%2Fstreams")); + Assert.That(context.Response.Headers.SetCookie.ToString(), Does.Contain($"{UiCredentialCookie.OAuthCookieName}={JwtToken}")); + Assert.That(handler.Body, Does.Contain("grant_type=authorization_code")); + Assert.That(handler.Body, Does.Contain("client_id=eventstore-ui")); + Assert.That(handler.Body, Does.Contain("redirect_uri=https%3A%2F%2Fnode.example.test%2Fui%2Fauth%2Foauth%2Fcallback")); + Assert.That(handler.Body, Does.Contain("code_verifier=")); + } + + [Test] + public async Task callback_redirects_to_return_url_when_admin_ui_is_disabled() + { + var handler = new TokenHandler(); + var service = Service(handler, adminUiEnabled: false); + var challengeContext = HttpsContext(); + var challenge = service.CreateCodeChallenge(challengeContext); + var context = HttpsContext(); + context.Request.Headers.Cookie = challengeContext.Response.Headers.SetCookie.ToString().Split(';')[0]; + context.Request.Query = new QueryCollection(new Dictionary + { + ["code"] = new StringValues("authorization-code"), + ["state"] = new StringValues(State(challenge.CodeChallengeCorrelationId)) + }); + + var result = await service.HandleCallback(context, CancellationToken.None); + await result.ExecuteAsync(context); + + Assert.AreEqual(HttpStatusCode.Redirect, (HttpStatusCode)context.Response.StatusCode); + Assert.That(context.Response.Headers.Location.ToString(), Is.EqualTo("/")); + Assert.That(context.Response.Headers.SetCookie.ToString(), Does.Contain($"{UiCredentialCookie.OAuthCookieName}={JwtToken}")); + } + + [Test] + public async Task callback_rejects_opaque_token_response() + { + var handler = new TokenHandler("""{"access_token":"opaque-token"}"""); + var service = Service(handler); + var challengeContext = HttpsContext(); + var challenge = service.CreateCodeChallenge(challengeContext); + var context = HttpsContext(); + context.Request.Headers.Cookie = challengeContext.Response.Headers.SetCookie.ToString().Split(';')[0]; + context.Request.Query = new QueryCollection(new Dictionary + { + ["code"] = new StringValues("authorization-code"), + ["state"] = new StringValues(State(challenge.CodeChallengeCorrelationId)) + }); + + var result = await service.HandleCallback(context, CancellationToken.None); + await result.ExecuteAsync(context); + + Assert.AreEqual(HttpStatusCode.Redirect, (HttpStatusCode)context.Response.StatusCode); + Assert.That(context.Response.Headers.Location.ToString(), Is.EqualTo("/ui/signin?returnUrl=%2Fui%2Fstreams&oauth_error=unsupported_token")); + Assert.That(context.Response.Headers.SetCookie.ToString(), Does.Not.Contain($"{UiCredentialCookie.OAuthCookieName}=")); + } + + [Test] + public async Task callback_rejects_token_that_fails_validation() + { + var handler = new TokenHandler($$"""{"access_token":"{{CreateToken(audience: "other-service")}}"}"""); + var service = Service(handler); + var challengeContext = HttpsContext(); + var challenge = service.CreateCodeChallenge(challengeContext); + var context = HttpsContext(); + context.Request.Headers.Cookie = challengeContext.Response.Headers.SetCookie.ToString().Split(';')[0]; + context.Request.Query = new QueryCollection(new Dictionary + { + ["code"] = new StringValues("authorization-code"), + ["state"] = new StringValues(State(challenge.CodeChallengeCorrelationId)) + }); + + var result = await service.HandleCallback(context, CancellationToken.None); + await result.ExecuteAsync(context); + + Assert.AreEqual(HttpStatusCode.Redirect, (HttpStatusCode)context.Response.StatusCode); + Assert.That(context.Response.Headers.Location.ToString(), Is.EqualTo("/ui/signin?returnUrl=%2Fui%2Fstreams&oauth_error=invalid_token")); + Assert.That(context.Response.Headers.SetCookie.ToString(), Does.Not.Contain($"{UiCredentialCookie.OAuthCookieName}=")); + } + + [Test] + public async Task callback_without_matching_challenge_cookie_does_not_exchange_code() + { + var handler = new TokenHandler(); + var service = Service(handler); + var context = HttpsContext(); + context.Request.Query = new QueryCollection(new Dictionary + { + ["code"] = new StringValues("authorization-code"), + ["state"] = new StringValues(State("not-this-browser")) + }); + + var result = await service.HandleCallback(context, CancellationToken.None); + await result.ExecuteAsync(context); + + Assert.AreEqual(HttpStatusCode.Redirect, (HttpStatusCode)context.Response.StatusCode); + Assert.That(context.Response.Headers.Location.ToString(), Is.EqualTo("/ui/signin?returnUrl=%2Fui%2Fstreams&oauth_error=invalid_state")); + Assert.That(context.Response.Headers.SetCookie.ToString(), Does.Not.Contain($"{UiCredentialCookie.OAuthCookieName}=access-token")); + Assert.That(handler.Body, Is.Empty); + } + + [Test] + public async Task callback_with_provider_error_preserves_return_url_without_exchanging_code() + { + var handler = new TokenHandler(); + var service = Service(handler); + var challengeContext = HttpsContext(); + var challenge = service.CreateCodeChallenge(challengeContext); + var context = HttpsContext(); + context.Request.Headers.Cookie = challengeContext.Response.Headers.SetCookie.ToString().Split(';')[0]; + context.Request.Query = new QueryCollection(new Dictionary + { + ["error"] = new StringValues("access_denied"), + ["state"] = new StringValues(State(challenge.CodeChallengeCorrelationId)) + }); + + var result = await service.HandleCallback(context, CancellationToken.None); + await result.ExecuteAsync(context); + + Assert.AreEqual(HttpStatusCode.Redirect, (HttpStatusCode)context.Response.StatusCode); + Assert.That(context.Response.Headers.Location.ToString(), Is.EqualTo("/ui/signin?returnUrl=%2Fui%2Fstreams&oauth_error=provider_error")); + Assert.That(context.Response.Headers.SetCookie.ToString(), Does.Not.Contain($"{UiCredentialCookie.OAuthCookieName}=access-token")); + Assert.That(handler.Body, Is.Empty); + } + + [Test] + public async Task callback_with_provider_error_redirects_to_return_url_when_admin_ui_is_disabled() + { + var handler = new TokenHandler(); + var service = Service(handler, adminUiEnabled: false); + var challengeContext = HttpsContext(); + var challenge = service.CreateCodeChallenge(challengeContext); + var context = HttpsContext(); + context.Request.Headers.Cookie = challengeContext.Response.Headers.SetCookie.ToString().Split(';')[0]; + context.Request.Query = new QueryCollection(new Dictionary + { + ["error"] = new StringValues("access_denied"), + ["state"] = new StringValues(State(challenge.CodeChallengeCorrelationId)) + }); + + var result = await service.HandleCallback(context, CancellationToken.None); + await result.ExecuteAsync(context); + + Assert.AreEqual(HttpStatusCode.Redirect, (HttpStatusCode)context.Response.StatusCode); + Assert.That(context.Response.Headers.Location.ToString(), Is.EqualTo("/?oauth_error=provider_error")); + Assert.That(context.Response.Headers.SetCookie.ToString(), Does.Not.Contain($"{UiCredentialCookie.OAuthCookieName}=access-token")); + Assert.That(handler.Body, Is.Empty); + } + + [Test] + public async Task callback_deletes_pkce_cookie_when_callback_data_is_missing() + { + var handler = new TokenHandler(); + var service = Service(handler); + var challengeContext = HttpsContext(); + service.CreateCodeChallenge(challengeContext); + var context = HttpsContext(); + context.Request.Headers.Cookie = challengeContext.Response.Headers.SetCookie.ToString().Split(';')[0]; + + var result = await service.HandleCallback(context, CancellationToken.None); + await result.ExecuteAsync(context); + + Assert.AreEqual(HttpStatusCode.Redirect, (HttpStatusCode)context.Response.StatusCode); + Assert.That(context.Response.Headers.Location.ToString(), Is.EqualTo("/ui/signin?oauth_error=missing_callback")); + var setCookie = context.Response.Headers.SetCookie.ToString(); + Assert.That(setCookie, Does.Contain("eventstore-ui-oauth-pkce=;")); + Assert.That(setCookie, Does.Contain("path=/")); + Assert.That(setCookie, Does.Contain("secure")); + Assert.That(setCookie, Does.Contain("samesite=lax")); + Assert.That(setCookie, Does.Contain("httponly")); + Assert.That(handler.Body, Is.Empty); + } + + [Test] + public async Task callback_exchanges_code_with_redirect_uri_from_state() + { + var handler = new TokenHandler(); + var service = Service(handler); + var challengeContext = HttpsContext(); + var challenge = service.CreateCodeChallenge(challengeContext); + var context = HttpsContext(); + context.Request.Scheme = "http"; + context.Request.Host = new HostString("internal-node:2113"); + context.Request.Headers.Cookie = challengeContext.Response.Headers.SetCookie.ToString().Split(';')[0]; + context.Request.Query = new QueryCollection(new Dictionary + { + ["code"] = new StringValues("authorization-code"), + ["state"] = new StringValues(State(challenge.CodeChallengeCorrelationId, "https://public.example.test/ui/auth/oauth/callback")) + }); + + var result = await service.HandleCallback(context, CancellationToken.None); + await result.ExecuteAsync(context); + + Assert.AreEqual(HttpStatusCode.Redirect, (HttpStatusCode)context.Response.StatusCode); + Assert.That(handler.Body, Does.Contain("redirect_uri=https%3A%2F%2Fpublic.example.test%2Fui%2Fauth%2Foauth%2Fcallback")); + Assert.That(handler.Body, Does.Not.Contain("redirect_uri=http%3A%2F%2Finternal-node%3A2113")); + } + + private static ClusterVNodeOptions.OAuthOptions Options() => new() + { + Issuer = "https://login.example.test", + Audiences = ["eventstore"], + TokenEndpoint = "https://login.example.test/token", + ClientId = "eventstore-ui" + }; + + private static string State(string correlationId, string redirectUri = "https://node.example.test/ui/auth/oauth/callback") => + Convert.ToBase64String(Encoding.UTF8.GetBytes($$"""{"code_challenge_correlation_id":"{{correlationId}}","return_url":"/ui/streams","redirect_uri":"{{redirectUri}}"}""")); + + private static OAuthBrowserFlowService Service(TokenHandler handler, bool adminUiEnabled = true) + { + var services = new ServiceCollection() + .AddLogging() + .AddDataProtection() + .Services + .BuildServiceProvider(); + return new OAuthBrowserFlowService( + Options(), + new HttpClient(handler), + TimeProvider.System, + services.GetRequiredService(), + new OAuthTokenValidator(Options(), _ => new ValueTask(CreateValidationParameters())), + adminUiEnabled); + } + + private static string CreateToken(string audience) + { + var descriptor = new SecurityTokenDescriptor + { + Issuer = "https://login.example.test", + Audience = audience, + Subject = new ClaimsIdentity([new Claim("sub", "alice")]), + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + SigningCredentials = new SigningCredentials(SigningKey, SecurityAlgorithms.HmacSha256) + }; + + return new JsonWebTokenHandler().CreateToken(descriptor); + } + + private static TokenValidationParameters CreateValidationParameters() => + new() + { + ValidateIssuer = true, + ValidIssuer = "https://login.example.test", + ValidateAudience = true, + ValidAudiences = ["eventstore"], + ValidateIssuerSigningKey = true, + IssuerSigningKey = SigningKey, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + NameClaimType = "sub", + RoleClaimType = "roles" + }; + + private static DefaultHttpContext HttpsContext() + { + var context = new DefaultHttpContext + { + RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider() + }; + context.Request.Scheme = "https"; + context.Request.Host = new HostString("node.example.test"); + return context; + } + + private sealed class TokenHandler : HttpMessageHandler + { + private readonly string _response; + public string Body { get; private set; } = ""; + + public TokenHandler() + : this($$"""{"access_token":"{{JwtToken}}"}""") + { + } + + public TokenHandler(string response) => + _response = response; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Body = await request.Content.ReadAsStringAsync(cancellationToken); + return new(HttpStatusCode.OK) + { + Content = new StringContent(_response, Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/src/EventStore.Core.Tests/Authentication/SecurityBrowserServiceTests.cs b/src/EventStore.Core.Tests/Authentication/SecurityBrowserServiceTests.cs new file mode 100644 index 0000000000..a0b1a97ccc --- /dev/null +++ b/src/EventStore.Core.Tests/Authentication/SecurityBrowserServiceTests.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Security.Claims; +using EventStore.ClusterNode.Components.Services; +using EventStore.Plugins.Authentication; +using Microsoft.AspNetCore.Http; +using NUnit.Framework; + +namespace EventStore.Core.Tests.Authentication; + +[TestFixture] +public class SecurityBrowserServiceTests +{ + [Test] + public void does_not_enable_oauth_browser_flow_with_blank_scope() + { + var service = new SecurityBrowserService(new BrowserFlowAuthenticationProvider([ + new("authorization_endpoint", "https://login.example.test/oauth2/auth"), + new("client_id", "eventstore-ui"), + new("code_challenge_uri", "/oauth/challenge"), + new("redirect_uri", "/oauth/callback"), + new("response_type", "code"), + new("scope", "") + ]), supportsPassword: false); + + var info = service.AuthenticationInfo(); + + Assert.That(info.SupportsOAuthBrowserFlow, Is.False); + } + + [Test] + public void does_not_enable_basic_for_certificate_only_transport_scheme() + { + var service = new SecurityBrowserService( + new BrowserFlowAuthenticationProvider([], ["Basic", "UserCertificate"]), + supportsPassword: false); + + var info = service.AuthenticationInfo(); + + Assert.That(info.SupportsBasic, Is.False); + } + + [Test] + public void enables_basic_when_password_authentication_is_configured() + { + var service = new SecurityBrowserService( + new BrowserFlowAuthenticationProvider([], ["Basic", "UserCertificate"]), + supportsPassword: true); + + var info = service.AuthenticationInfo(); + + Assert.That(info.SupportsBasic, Is.True); + } + + private sealed class BrowserFlowAuthenticationProvider( + IReadOnlyList> publicProperties, + IReadOnlyList schemes = null) + : AuthenticationProviderBase(name: "test") + { + public override void Authenticate(AuthenticationRequest authenticationRequest) => + authenticationRequest.Authenticated( + new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "test")], "test"))); + + public override IReadOnlyList GetSupportedAuthenticationSchemes() => schemes ?? ["Bearer"]; + + public override IEnumerable> GetPublicProperties() => publicProperties; + } +} diff --git a/src/EventStore.Core.Tests/EventStore.Core.Tests.csproj b/src/EventStore.Core.Tests/EventStore.Core.Tests.csproj index 7f71bec0bc..3be13ef772 100644 --- a/src/EventStore.Core.Tests/EventStore.Core.Tests.csproj +++ b/src/EventStore.Core.Tests/EventStore.Core.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/authentication_middleware_should.cs b/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/authentication_middleware_should.cs index ec0a1fe026..ae495ec380 100644 --- a/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/authentication_middleware_should.cs +++ b/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/authentication_middleware_should.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using EventStore.Core.Services.Transport.Http; @@ -50,6 +51,22 @@ public async Task rewrite_anonymous_redirects_without_a_user_agent_to_unauthoriz Assert.AreEqual("X-Basic realm=\"ESDB\"", context.Response.Headers.WWWAuthenticate); } + [Test] + public async Task includes_every_supported_authentication_scheme_in_challenge() + { + var context = new DefaultHttpContext(); + var middleware = new AuthenticationMiddleware( + [], + new TestAuthenticationProvider(["Basic", "Bearer"])); + + await middleware.InvokeAsync(context, _ => Task.CompletedTask); + + Assert.That(context.Response.Headers.WWWAuthenticate.ToArray(), Is.EqualTo(new[] { + "X-Basic realm=\"ESDB\"", + "X-Bearer realm=\"ESDB\"" + })); + } + [Test] public async Task preserve_authenticated_redirects_for_non_browser_requests() { @@ -101,11 +118,11 @@ public bool Authenticate(HttpContext context, out HttpAuthenticationRequest requ } } - private sealed class TestAuthenticationProvider : AuthenticationProviderBase + private sealed class TestAuthenticationProvider(IReadOnlyList schemes = null) : AuthenticationProviderBase { public override void Authenticate(AuthenticationRequest authenticationRequest) => throw new System.NotImplementedException(); - public override IReadOnlyList GetSupportedAuthenticationSchemes() => ["Basic"]; + public override IReadOnlyList GetSupportedAuthenticationSchemes() => schemes ?? ["Basic"]; } } diff --git a/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/bearer_http_authentication_provider_should.cs b/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/bearer_http_authentication_provider_should.cs new file mode 100644 index 0000000000..86d55324df --- /dev/null +++ b/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/bearer_http_authentication_provider_should.cs @@ -0,0 +1,54 @@ +using EventStore.Core.Services.Transport.Http.Authentication; +using EventStore.Plugins.Authentication; +using Microsoft.AspNetCore.Http; +using NUnit.Framework; + +namespace EventStore.Core.Tests.Services.Transport.Http.Authentication; + +[TestFixture] +public class bearer_http_authentication_provider_should +{ + [Test] + public void pass_bearer_token_as_jwt_to_authentication_provider() + { + var authenticationProvider = new RecordingAuthenticationProvider(); + var provider = new BearerHttpAuthenticationProvider(authenticationProvider); + var context = new DefaultHttpContext(); + context.Request.Headers.Authorization = "Bearer access-token"; + + var handled = provider.Authenticate(context, out var request); + + Assert.True(handled); + Assert.NotNull(request); + Assert.AreEqual(1, authenticationProvider.Calls); + Assert.AreEqual("access-token", authenticationProvider.Token); + } + + [Test] + public void ignore_non_bearer_authorization_header() + { + var authenticationProvider = new RecordingAuthenticationProvider(); + var provider = new BearerHttpAuthenticationProvider(authenticationProvider); + var context = new DefaultHttpContext(); + context.Request.Headers.Authorization = "Basic credentials"; + + var handled = provider.Authenticate(context, out var request); + + Assert.False(handled); + Assert.Null(request); + Assert.AreEqual(0, authenticationProvider.Calls); + } + + private sealed class RecordingAuthenticationProvider : AuthenticationProviderBase + { + public int Calls { get; private set; } + public string Token { get; private set; } + + public override void Authenticate(AuthenticationRequest authenticationRequest) + { + Calls++; + Token = authenticationRequest.GetToken("jwt"); + authenticationRequest.Unauthorized(); + } + } +} diff --git a/src/EventStore.Core.XUnit.Tests/Authentication/AuthenticationMethodNamesTests.cs b/src/EventStore.Core.XUnit.Tests/Authentication/AuthenticationMethodNamesTests.cs new file mode 100644 index 0000000000..464b0a2fdb --- /dev/null +++ b/src/EventStore.Core.XUnit.Tests/Authentication/AuthenticationMethodNamesTests.cs @@ -0,0 +1,70 @@ +using EventStore.Core.Authentication; +using FluentAssertions; +using Xunit; + +namespace EventStore.Core.XUnit.Tests.Authentication; + +public class AuthenticationMethodNamesTests +{ + [Fact] + public void defaults_to_password_method() + { + AuthenticationMethodNames.FromOptions(new()).Should().Equal(AuthenticationMethodNames.Password); + } + + [Fact] + public void maps_legacy_internal_to_password() + { + AuthenticationMethodNames.FromOptions(new() { AuthenticationType = "internal" }) + .Should() + .Equal(AuthenticationMethodNames.Password); + } + + [Fact] + public void normalizes_multiple_methods() + { + AuthenticationMethodNames.FromOptions(new() { Methods = ["Password", "OAuth", "oauth"] }) + .Should() + .Equal(AuthenticationMethodNames.Password, AuthenticationMethodNames.OAuth); + } + + [Fact] + public void methods_override_legacy_authentication_type() + { + AuthenticationMethodNames.FromOptions(new() { AuthenticationType = "ldaps", Methods = ["Password", "OAuth"] }) + .Should() + .Equal(AuthenticationMethodNames.Password, AuthenticationMethodNames.OAuth); + } + + [Fact] + public void detects_oauth_method() + { + AuthenticationMethodNames.IncludesOAuth(new() { Methods = ["Password", "OAuth"] }) + .Should() + .BeTrue(); + } + + [Fact] + public void oauth_uses_the_built_in_user_store() + { + AuthenticationMethodNames.IncludesBuiltInUserStore(new() { Methods = ["OAuth"] }) + .Should() + .BeTrue(); + } + + [Fact] + public void external_authentication_without_oauth_does_not_use_the_built_in_user_store() + { + AuthenticationMethodNames.IncludesBuiltInUserStore(new() { AuthenticationType = "ldaps" }) + .Should() + .BeFalse(); + } + + [Fact] + public void keeps_legacy_authentication_type_when_methods_are_not_configured() + { + AuthenticationMethodNames.FromOptions(new() { AuthenticationType = "ldaps" }) + .Should() + .Equal("ldaps"); + } +} diff --git a/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsTests.cs b/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsTests.cs index ab6fbc4be8..f5a988ab10 100644 --- a/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsTests.cs +++ b/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsTests.cs @@ -254,6 +254,87 @@ public void can_set_gossip_seed_values_via_array() }); } + [Fact] + public void can_set_authentication_methods_from_auth_section() + { + var config = new ConfigurationBuilder() + .AddEventStoreDefaultValues() + .AddSection(EventStoreConfigurationKeys.Prefix, builder => builder + .AddInMemoryCollection([ + new KeyValuePair("Auth:Methods:0", "password"), + new KeyValuePair("Auth:Methods:1", "oauth"), + ])) + .Build(); + + var options = ClusterVNodeOptions.FromConfiguration(config); + + options.Auth.Methods.Should().Equal("password", "oauth"); + } + + [Fact] + public void can_set_authentication_methods_from_environment_variables() + { + var config = new ConfigurationBuilder() + .AddEventStoreDefaultValues() + .AddEventStoreEnvironmentVariables( + ("EVENTSTORE__AUTH__METHODS__0", "password"), + ("EVENTSTORE__AUTH__METHODS__1", "oauth")) + .Build(); + + var options = ClusterVNodeOptions.FromConfiguration(config); + + options.Auth.Methods.Should().Equal("password", "oauth"); + } + + [Fact] + public void can_set_authentication_methods_from_command_line() + { + var config = new ConfigurationBuilder() + .AddEventStoreDefaultValues() + .AddEventStoreCommandLine("--auth:methods:0", "password", "--auth:methods:1", "oauth") + .Build(); + + var options = ClusterVNodeOptions.FromConfiguration(config); + + options.Auth.Methods.Should().Equal("password", "oauth"); + } + + [Fact] + public void can_set_oauth_options_from_auth_section() + { + var config = new ConfigurationBuilder() + .AddEventStoreDefaultValues() + .AddSection(EventStoreConfigurationKeys.Prefix, builder => builder + .AddInMemoryCollection([ + new KeyValuePair("Auth:OAuth:Issuer", "https://identity.example.com"), + new KeyValuePair("Auth:OAuth:Audiences:0", "eventstore"), + ])) + .Build(); + + var options = ClusterVNodeOptions.FromConfiguration(config); + + options.Auth.OAuth.Issuer.Should().Be("https://identity.example.com"); + options.Auth.OAuth.Audiences.Should().Equal("eventstore"); + } + + [Fact] + public void auth_section_preserves_flat_authentication_type() + { + var config = new ConfigurationBuilder() + .AddEventStoreDefaultValues() + .AddSection(EventStoreConfigurationKeys.Prefix, builder => builder + .AddInMemoryCollection([ + new KeyValuePair("AuthenticationType", "external"), + new KeyValuePair("Auth:OAuth:Issuer", "https://identity.example.com"), + ])) + .Build(); + + var options = ClusterVNodeOptions.FromConfiguration(config); + + options.Auth.AuthenticationType.Should().Be("external"); + options.Auth.OAuth.Issuer.Should().Be("https://identity.example.com"); + } + [Theory] [InlineData("127.0.0.1", "You must specify the ports in the gossip seed.")] [InlineData("127.0.0.1:3.1415", "Invalid format for gossip seed port: 3.1415.")] diff --git a/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsValidatorTests.cs b/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsValidatorTests.cs index 13375f5a3e..a9b411f91e 100644 --- a/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsValidatorTests.cs +++ b/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsValidatorTests.cs @@ -1,5 +1,7 @@ using System; using EventStore.Common.Exceptions; +using EventStore.Core.Authentication; +using EventStore.Core.Services; using Xunit; namespace EventStore.Core.XUnit.Tests.Configuration; @@ -81,4 +83,65 @@ public void archiver_not_compatible_with_unsafe_ignore_hard_delete() ClusterVNodeOptionsValidator.Validate(options); }); } + + [Fact] + public void startup_allows_default_user_passwords_when_oauth_needs_certificate_users() + { + var options = new ClusterVNodeOptions + { + Auth = new() + { + Methods = [AuthenticationMethodNames.OAuth] + }, + DefaultUser = new() + { + DefaultAdminPassword = "admin-password", + DefaultOpsPassword = "ops-password" + } + }; + + Assert.True(ClusterVNodeOptionsValidator.ValidateForStartup(options)); + } + + [Fact] + public void startup_rejects_default_user_passwords_when_builtin_user_store_is_not_used() + { + var options = new ClusterVNodeOptions + { + Auth = new() + { + Methods = ["external"] + }, + DefaultUser = new() + { + DefaultAdminPassword = "admin-password", + DefaultOpsPassword = "ops-password" + } + }; + + Assert.False(ClusterVNodeOptionsValidator.ValidateForStartup(options)); + } + + [Fact] + public void startup_rejects_default_user_passwords_when_auth_is_disabled() + { + var options = new ClusterVNodeOptions + { + Application = new() + { + Insecure = true + }, + Auth = new() + { + Methods = [AuthenticationMethodNames.OAuth] + }, + DefaultUser = new() + { + DefaultAdminPassword = "admin-password", + DefaultOpsPassword = SystemUsers.DefaultOpsPassword + } + }; + + Assert.False(ClusterVNodeOptionsValidator.ValidateForStartup(options)); + } } diff --git a/src/EventStore.Core/Authentication/AuthenticationMethodNames.cs b/src/EventStore.Core/Authentication/AuthenticationMethodNames.cs new file mode 100644 index 0000000000..2ce5bf4a20 --- /dev/null +++ b/src/EventStore.Core/Authentication/AuthenticationMethodNames.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace EventStore.Core.Authentication; + +public static class AuthenticationMethodNames +{ + public const string Password = "password"; + public const string OAuth = "oauth"; + private const string LegacyInternal = "internal"; + + public static IReadOnlyList FromOptions(ClusterVNodeOptions.AuthOptions options) + { + var methods = (options.Methods ?? []) + .Where(method => !string.IsNullOrWhiteSpace(method)) + .Select(Normalize) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (methods.Length > 0) + { + return methods; + } + + return IsLegacyInternal(options.AuthenticationType) ? [Password] : [Normalize(options.AuthenticationType)]; + } + + public static bool IncludesPassword(ClusterVNodeOptions.AuthOptions options) => + FromOptions(options).Any(IsPassword); + + public static bool IncludesOAuth(ClusterVNodeOptions.AuthOptions options) => + FromOptions(options).Any(method => string.Equals(Normalize(method), OAuth, StringComparison.OrdinalIgnoreCase)); + + public static bool IncludesBuiltInUserStore(ClusterVNodeOptions.AuthOptions options) => + IncludesPassword(options) || IncludesOAuth(options); + + public static string Normalize(string method) => + IsLegacyInternal(method) ? Password : method?.Trim().ToLowerInvariant() ?? string.Empty; + + public static bool IsPassword(string method) => + string.Equals(Normalize(method), Password, StringComparison.OrdinalIgnoreCase); + + private static bool IsLegacyInternal(string method) => + string.Equals(method?.Trim(), LegacyInternal, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/EventStore.Core/Authentication/CompositeAuthenticationProvider.cs b/src/EventStore.Core/Authentication/CompositeAuthenticationProvider.cs new file mode 100644 index 0000000000..5f947d1df7 --- /dev/null +++ b/src/EventStore.Core/Authentication/CompositeAuthenticationProvider.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using EventStore.Plugins.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventStore.Core.Authentication; + +public class CompositeAuthenticationProvider(IReadOnlyList providers) + : AuthenticationProviderBase(name: "methods", diagnosticsName: "CompositeAuthentication") +{ + public override Task Initialize() => + Task.WhenAll(providers.Select(provider => provider.Initialize())); + + public override void Authenticate(AuthenticationRequest authenticationRequest) + { + var scheme = SelectScheme(authenticationRequest); + var provider = providers.FirstOrDefault(candidate => + candidate.GetSupportedAuthenticationSchemes()?.Contains(scheme, StringComparer.OrdinalIgnoreCase) == true); + + if (provider is null) + { + authenticationRequest.Unauthorized(); + return; + } + + provider.Authenticate(authenticationRequest); + } + + public override IEnumerable> GetPublicProperties() => + providers.SelectMany(provider => provider.GetPublicProperties() ?? []); + + public override void ConfigureEndpoints(IEndpointRouteBuilder endpointRouteBuilder) + { + foreach (var provider in providers) + { + provider.ConfigureEndpoints(endpointRouteBuilder); + } + } + + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + foreach (var provider in providers) + { + provider.ConfigureServices(services, configuration); + } + } + + public override void ConfigureApplication(IApplicationBuilder app, IConfiguration configuration) + { + foreach (var provider in providers) + { + provider.ConfigureApplication(app, configuration); + } + } + + public override IReadOnlyList GetSupportedAuthenticationSchemes() => + providers + .SelectMany(provider => provider.GetSupportedAuthenticationSchemes() ?? []) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static string SelectScheme(AuthenticationRequest authenticationRequest) + { + if (!string.IsNullOrWhiteSpace(authenticationRequest.GetToken("jwt"))) + { + return "Bearer"; + } + + return authenticationRequest.HasValidClientCertificate ? "UserCertificate" : "Basic"; + } +} diff --git a/src/EventStore.Core/Authentication/CompositeAuthenticationProviderFactory.cs b/src/EventStore.Core/Authentication/CompositeAuthenticationProviderFactory.cs new file mode 100644 index 0000000000..607cacbdf8 --- /dev/null +++ b/src/EventStore.Core/Authentication/CompositeAuthenticationProviderFactory.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Linq; +using EventStore.Plugins.Authentication; + +namespace EventStore.Core.Authentication; + +public class CompositeAuthenticationProviderFactory(IReadOnlyList factories) + : IAuthenticationProviderFactory +{ + public IAuthenticationProvider Build(bool logFailedAuthenticationAttempts) + { + var providers = factories + .Select(factory => factory.Build(logFailedAuthenticationAttempts)) + .ToArray(); + + return providers.Length == 1 ? providers[0] : new CompositeAuthenticationProvider(providers); + } +} diff --git a/src/EventStore.Core/Authentication/OAuth/OAuthAuthenticationProvider.cs b/src/EventStore.Core/Authentication/OAuth/OAuthAuthenticationProvider.cs new file mode 100644 index 0000000000..390cf7eff9 --- /dev/null +++ b/src/EventStore.Core/Authentication/OAuth/OAuthAuthenticationProvider.cs @@ -0,0 +1,227 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Common.Exceptions; +using EventStore.Plugins.Authentication; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Serilog; + +namespace EventStore.Core.Authentication.OAuth; + +public class OAuthAuthenticationProvider : AuthenticationProviderBase +{ + private static readonly ILogger Log = Serilog.Log.ForContext(); + + private readonly ClusterVNodeOptions.OAuthOptions _options; + private readonly bool _logFailedAuthenticationAttempts; + private readonly OAuthTokenValidator _tokenValidator; + + public OAuthAuthenticationProvider( + ClusterVNodeOptions.OAuthOptions options, + bool logFailedAuthenticationAttempts, + Func>? validationParametersFactory = null) + : base(name: "oauth", diagnosticsName: "OAuthAuthentication") + { + _options = options; + _logFailedAuthenticationAttempts = logFailedAuthenticationAttempts; + _tokenValidator = new OAuthTokenValidator(options, validationParametersFactory); + } + + public override void Authenticate(AuthenticationRequest authenticationRequest) => + _ = AuthenticateAsync(authenticationRequest); + + public override IReadOnlyList GetSupportedAuthenticationSchemes() => ["Bearer"]; + + public override IEnumerable> GetPublicProperties() + { + if (!string.IsNullOrWhiteSpace(_options.Issuer)) + { + yield return new("oauth_issuer", _options.Issuer); + } + + if (_options.Audiences.Length > 0) + { + yield return new("oauth_audiences", string.Join(",", _options.Audiences)); + } + + if (HasBrowserFlow(_options)) + { + yield return new("authorization_endpoint", BrowserAuthorizationEndpoint(_options)); + yield return new("client_id", _options.ClientId!); + yield return new("code_challenge_uri", _options.CodeChallengePath); + yield return new("redirect_uri", _options.RedirectPath); + yield return new("response_type", "code"); + yield return new("scope", string.Join(" ", _options.Scopes)); + } + } + + private async Task AuthenticateAsync(AuthenticationRequest authenticationRequest) + { + try + { + var token = authenticationRequest.GetToken("jwt"); + if (string.IsNullOrWhiteSpace(token)) + { + authenticationRequest.Unauthorized(); + return; + } + + var result = await _tokenValidator.ValidateTokenAsync(token, CancellationToken.None); + if (!result.IsValid) + { + if (_logFailedAuthenticationAttempts) + { + Log.Warning("Authentication Failed for {Id}: {Reason}", authenticationRequest.Id, result.Exception?.Message ?? "Invalid OAuth token."); + } + + authenticationRequest.Unauthorized(); + return; + } + + authenticationRequest.Authenticated(CreatePrincipal(result.ClaimsIdentity)); + } + catch (Exception ex) + { + Log.Error(ex, "OAuth authentication failed unexpectedly."); + authenticationRequest.Error(); + } + } + + private ClaimsPrincipal CreatePrincipal(ClaimsIdentity claimsIdentity) + { + var claims = claimsIdentity.Claims.ToList(); + var name = claimsIdentity.FindFirst(_options.NameClaimType)?.Value; + if (!string.IsNullOrWhiteSpace(name) && claims.All(claim => claim.Type != ClaimTypes.Name)) + { + claims.Add(new Claim(ClaimTypes.Name, name)); + } + + foreach (var role in ExpandRoleClaims(claimsIdentity)) + { + if (claims.All(claim => claim.Type != ClaimTypes.Role || claim.Value != role)) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + } + + return new(new ClaimsIdentity(claims, "OAuth", ClaimTypes.Name, ClaimTypes.Role)); + } + + private IEnumerable ExpandRoleClaims(ClaimsIdentity claimsIdentity) + { + foreach (var claim in claimsIdentity.FindAll(_options.RoleClaimType)) + { + if (claim.Value.StartsWith("[", StringComparison.Ordinal)) + { + foreach (var role in ParseJsonArrayClaim(claim.Value)) + { + yield return role; + } + + continue; + } + + yield return claim.Value; + } + } + + private static IEnumerable ParseJsonArrayClaim(string value) + { + string[]? values; + try + { + values = JsonSerializer.Deserialize(value); + } + catch (JsonException) + { + yield break; + } + + if (values is null) + { + yield break; + } + + foreach (var item in values.Where(item => !string.IsNullOrWhiteSpace(item))) + { + yield return item; + } + } + + private static bool HasBrowserFlow(ClusterVNodeOptions.OAuthOptions options) => + !string.IsNullOrWhiteSpace(options.ClientId) && + !string.IsNullOrWhiteSpace(options.AuthorizationEndpoint) && + !string.IsNullOrWhiteSpace(options.TokenEndpoint) && + options.Scopes.Any(scope => !string.IsNullOrWhiteSpace(scope)); + + private static string BrowserAuthorizationEndpoint(ClusterVNodeOptions.OAuthOptions options) => + options.AuthorizationEndpoint!; +} + +public sealed class OAuthTokenValidator +{ + private readonly JsonWebTokenHandler _tokenHandler = new() { MapInboundClaims = false }; + private readonly Func> _validationParametersFactory; + + public OAuthTokenValidator( + ClusterVNodeOptions.OAuthOptions options, + Func>? validationParametersFactory = null) => + _validationParametersFactory = validationParametersFactory ?? CreateValidationParametersFactory(options); + + public async ValueTask ValidateTokenAsync(string token, CancellationToken cancellationToken) + { + var validationParameters = await _validationParametersFactory(cancellationToken); + return await _tokenHandler.ValidateTokenAsync(token, validationParameters); + } + + private static Func> CreateValidationParametersFactory( + ClusterVNodeOptions.OAuthOptions options) + { + if (string.IsNullOrWhiteSpace(options.Issuer)) + { + throw new InvalidConfigurationException("OAuth authentication requires Auth:OAuth:Issuer."); + } + + if (options.Audiences.Length == 0) + { + throw new InvalidConfigurationException("OAuth authentication requires at least one Auth:OAuth:Audiences value."); + } + + var metadataAddress = string.IsNullOrWhiteSpace(options.MetadataAddress) + ? $"{options.Issuer.TrimEnd('/')}/.well-known/openid-configuration" + : options.MetadataAddress; + + var retriever = new HttpDocumentRetriever { RequireHttps = options.RequireHttpsMetadata }; + var configurationManager = new ConfigurationManager( + metadataAddress, + new OpenIdConnectConfigurationRetriever(), + retriever); + + return async cancellationToken => + { + var configuration = await configurationManager.GetConfigurationAsync(cancellationToken); + return new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = configuration.Issuer ?? options.Issuer, + ValidateAudience = true, + ValidAudiences = options.Audiences, + ValidateIssuerSigningKey = true, + IssuerSigningKeys = configuration.SigningKeys, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(options.ClockSkewSeconds), + NameClaimType = options.NameClaimType, + RoleClaimType = options.RoleClaimType + }; + }; + } +} diff --git a/src/EventStore.Core/Authentication/OAuth/OAuthAuthenticationProviderFactory.cs b/src/EventStore.Core/Authentication/OAuth/OAuthAuthenticationProviderFactory.cs new file mode 100644 index 0000000000..12deb15ef5 --- /dev/null +++ b/src/EventStore.Core/Authentication/OAuth/OAuthAuthenticationProviderFactory.cs @@ -0,0 +1,9 @@ +using EventStore.Plugins.Authentication; + +namespace EventStore.Core.Authentication.OAuth; + +public class OAuthAuthenticationProviderFactory(ClusterVNodeOptions.OAuthOptions options) : IAuthenticationProviderFactory +{ + public IAuthenticationProvider Build(bool logFailedAuthenticationAttempts) => + new OAuthAuthenticationProvider(options, logFailedAuthenticationAttempts); +} diff --git a/src/EventStore.Core/Authentication/UserCertificateAuthenticationProvider.cs b/src/EventStore.Core/Authentication/UserCertificateAuthenticationProvider.cs new file mode 100644 index 0000000000..0a731b2332 --- /dev/null +++ b/src/EventStore.Core/Authentication/UserCertificateAuthenticationProvider.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using EventStore.Plugins.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventStore.Core.Authentication; + +public class UserCertificateAuthenticationProvider(IAuthenticationProvider inner) + : AuthenticationProviderBase(name: "user-certificate", diagnosticsName: "UserCertificateAuthentication") +{ + public override Task Initialize() => + inner.Initialize(); + + public override void Authenticate(AuthenticationRequest authenticationRequest) + { + if (!authenticationRequest.HasValidClientCertificate) + { + authenticationRequest.Unauthorized(); + return; + } + + inner.Authenticate(authenticationRequest); + } + + public override IEnumerable> GetPublicProperties() => + inner.GetPublicProperties() ?? []; + + public override void ConfigureEndpoints(IEndpointRouteBuilder endpointRouteBuilder) => + inner.ConfigureEndpoints(endpointRouteBuilder); + + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) => + inner.ConfigureServices(services, configuration); + + public override void ConfigureApplication(IApplicationBuilder app, IConfiguration configuration) => + inner.ConfigureApplication(app, configuration); + + public override IReadOnlyList GetSupportedAuthenticationSchemes() => ["Basic", "UserCertificate"]; +} diff --git a/src/EventStore.Core/Authentication/UserCertificateAuthenticationProviderFactory.cs b/src/EventStore.Core/Authentication/UserCertificateAuthenticationProviderFactory.cs new file mode 100644 index 0000000000..57363a8f48 --- /dev/null +++ b/src/EventStore.Core/Authentication/UserCertificateAuthenticationProviderFactory.cs @@ -0,0 +1,10 @@ +using EventStore.Plugins.Authentication; + +namespace EventStore.Core.Authentication; + +public class UserCertificateAuthenticationProviderFactory(IAuthenticationProviderFactory innerFactory) + : IAuthenticationProviderFactory +{ + public IAuthenticationProvider Build(bool logFailedAuthenticationAttempts) => + new UserCertificateAuthenticationProvider(innerFactory.Build(logFailedAuthenticationAttempts)); +} diff --git a/src/EventStore.Core/ClusterVNode.cs b/src/EventStore.Core/ClusterVNode.cs index 79be2ca783..48a32c7972 100644 --- a/src/EventStore.Core/ClusterVNode.cs +++ b/src/EventStore.Core/ClusterVNode.cs @@ -1105,7 +1105,7 @@ GossipAdvertiseInfo GetGossipAdvertiseInfo() { ["projections"] = options.Projection.RunProjections != ProjectionType.None || options.DevMode.Dev, ["userManagement"] = - options.Auth.AuthenticationType == Opts.AuthenticationTypeDefault && + AuthenticationMethodNames.IncludesBuiltInUserStore(options.Auth) && !options.Application.AuthDisabled() }, _authenticationProvider diff --git a/src/EventStore.Core/Configuration/ClusterVNodeOptions.cs b/src/EventStore.Core/Configuration/ClusterVNodeOptions.cs index 04931447e8..f5e97800f2 100644 --- a/src/EventStore.Core/Configuration/ClusterVNodeOptions.cs +++ b/src/EventStore.Core/Configuration/ClusterVNodeOptions.cs @@ -78,7 +78,7 @@ public static ClusterVNodeOptions FromConfiguration(IConfigurationRoot configura DevMode = configuration.BindOptions(), DefaultUser = configuration.BindOptions(), Logging = configuration.BindOptions(), - Auth = configuration.BindOptions(), + Auth = BindOptions(configuration, nameof(Auth)), Certificate = configuration.BindOptions(), CertificateFile = configuration.BindOptions(), CertificateStore = configuration.BindOptions(), @@ -94,6 +94,18 @@ public static ClusterVNodeOptions FromConfiguration(IConfigurationRoot configura }; return options; + + static T BindOptions(IConfiguration configuration, string sectionName) where T : new() + { + var options = configuration.BindOptions(); + var section = configuration.GetSection(sectionName); + if (section.Exists()) + { + section.Bind(options); + } + + return options; + } } [Description("Default User Options")] @@ -235,12 +247,64 @@ public record AuthOptions [Description("Path to the configuration file for authorization configuration (if applicable).")] public string? AuthorizationConfig { get; init; } - [Description("The type of Authentication to use.")] + [Description("Legacy authentication provider name. Prefer Auth:Methods for new configurations.")] public string AuthenticationType { get; init; } = "internal"; + [Description("Authentication methods enabled by the node. PostgreSQL also models client authentication this way, keeping password and OAuth explicit.")] + public string[] Methods { get; init; } = []; + [Description("Path to the configuration file for Authentication configuration (if applicable).")] public string? AuthenticationConfig { get; init; } + [Description("OAuth authentication options.")] + public OAuthOptions OAuth { get; init; } = new(); + + } + + public record OAuthOptions + { + [Description("OpenID Connect issuer URL that signs accepted access tokens.")] + public string? Issuer { get; init; } + + [Description("OpenID Connect discovery document URL. Defaults to '/.well-known/openid-configuration'.")] + public string? MetadataAddress { get; init; } + + [Description("Accepted token audiences for this node.")] + public string[] Audiences { get; init; } = []; + + [Description("OAuth authorization endpoint used by the browser sign-in flow.")] + public string? AuthorizationEndpoint { get; init; } + + [Description("OAuth token endpoint used by the browser sign-in flow.")] + public string? TokenEndpoint { get; init; } + + [Description("OAuth public client id used by the browser sign-in flow.")] + public string? ClientId { get; init; } + + [Description("OAuth client secret used by confidential browser sign-in clients."), + Sensitive] + public string? ClientSecret { get; init; } + + [Description("OAuth scopes requested by the browser sign-in flow.")] + public string[] Scopes { get; init; } = ["openid", "profile"]; + + [Description("Path that receives the OAuth authorization-code callback.")] + public string RedirectPath { get; init; } = "/ui/auth/oauth/callback"; + + [Description("Path that creates PKCE code challenges for the OAuth browser sign-in flow.")] + public string CodeChallengePath { get; init; } = "/ui/auth/oauth/code-challenge"; + + [Description("Claim used as the authenticated user name.")] + public string NameClaimType { get; init; } = "sub"; + + [Description("Claim whose values are mapped to EventStore role claims.")] + public string RoleClaimType { get; init; } = "roles"; + + [Description("Require HTTPS for OpenID Connect metadata.")] + public bool RequireHttpsMetadata { get; init; } = true; + + [Description("Allowed token clock skew in seconds.")] + public int ClockSkewSeconds { get; init; } = 300; } [Description("Certificate Options (from file)")] diff --git a/src/EventStore.Core/Configuration/ClusterVNodeOptionsValidator.cs b/src/EventStore.Core/Configuration/ClusterVNodeOptionsValidator.cs index f0f6c29e56..8c0cb75f5a 100644 --- a/src/EventStore.Core/Configuration/ClusterVNodeOptionsValidator.cs +++ b/src/EventStore.Core/Configuration/ClusterVNodeOptionsValidator.cs @@ -2,6 +2,7 @@ using System; using System.IO; using EventStore.Common.Exceptions; +using EventStore.Core.Authentication; using EventStore.Core.Services; using EventStore.Core.TransactionLog.Chunks; using EventStore.Core.Util; @@ -149,17 +150,18 @@ public static bool ValidateForStartup(ClusterVNodeOptions options) return false; } - if (options.Application.AuthDisabled() || options.Auth.AuthenticationType != Opts.AuthenticationTypeDefault) + var needsDefaultUsers = AuthenticationMethodNames.IncludesBuiltInUserStore(options.Auth); + if (options.Application.AuthDisabled() || !needsDefaultUsers) { if (options.DefaultUser.DefaultAdminPassword != SystemUsers.DefaultAdminPassword) { - Log.Error("Cannot set default admin password when not using the internal authentication."); + Log.Error("Cannot set default admin password when password authentication is not enabled."); return false; } if (options.DefaultUser.DefaultOpsPassword != SystemUsers.DefaultOpsPassword) { - Log.Error("Cannot set default ops password when not using the internal authentication."); + Log.Error("Cannot set default ops password when password authentication is not enabled."); return false; } } diff --git a/src/EventStore.Core/EventStore.Core.csproj b/src/EventStore.Core/EventStore.Core.csproj index a43c999064..aa1bc4a9e6 100644 --- a/src/EventStore.Core/EventStore.Core.csproj +++ b/src/EventStore.Core/EventStore.Core.csproj @@ -18,6 +18,8 @@ + + diff --git a/src/EventStore.Core/Services/Transport/Http/AuthenticationMiddleware.cs b/src/EventStore.Core/Services/Transport/Http/AuthenticationMiddleware.cs index 98b4f69bc6..76a06c3331 100644 --- a/src/EventStore.Core/Services/Transport/Http/AuthenticationMiddleware.cs +++ b/src/EventStore.Core/Services/Transport/Http/AuthenticationMiddleware.cs @@ -147,8 +147,11 @@ private async Task AddHttp1ChallengeHeaders(HttpContext context) var authSchemes = _authenticationProvider.GetSupportedAuthenticationSchemes(); if (authSchemes != null && authSchemes.Any()) { - //add "X-" in front to prevent any default browser behaviour e.g Basic Auth popups - context.Response.Headers.Append("WWW-Authenticate", $"X-{authSchemes.First()} realm=\"ESDB\""); + foreach (var scheme in authSchemes) + { + context.Response.Headers.Append("WWW-Authenticate", $"X-{scheme} realm=\"ESDB\""); + } + var properties = _authenticationProvider.GetPublicProperties(); if (properties != null && properties.Any()) { diff --git a/src/EventStore.Core/Telemetry/TelemetryService.cs b/src/EventStore.Core/Telemetry/TelemetryService.cs index b7015e6e41..c1bfc0eb63 100644 --- a/src/EventStore.Core/Telemetry/TelemetryService.cs +++ b/src/EventStore.Core/Telemetry/TelemetryService.cs @@ -11,6 +11,7 @@ using DotNext.Collections.Generic; using DotNext.Runtime.CompilerServices; using EventStore.Common.Utils; +using EventStore.Core.Authentication; using EventStore.Core.Bus; using EventStore.Core.Data; using EventStore.Core.Messages; @@ -223,7 +224,7 @@ private async ValueTask Handle(TelemetryMessage.Request message, CancellationTok ["disableTls"] = _nodeOptions.Application.DisableTls, ["runProjections"] = _nodeOptions.Projection.RunProjections.ToString(), ["authorizationType"] = _nodeOptions.Auth.AuthorizationType, - ["authenticationType"] = _nodeOptions.Auth.AuthenticationType + ["authenticationMethods"] = JsonSerializer.SerializeToNode(AuthenticationMethodNames.FromOptions(_nodeOptions.Auth)) })); message.Envelope.ReplyWith(new TelemetryMessage.Response( diff --git a/src/EventStore.Core/Util/Opts.cs b/src/EventStore.Core/Util/Opts.cs index 89d591bbbf..358400fa67 100644 --- a/src/EventStore.Core/Util/Opts.cs +++ b/src/EventStore.Core/Util/Opts.cs @@ -21,8 +21,6 @@ public static class Opts public const byte IndexBitnessVersionDefault = Index.PTableVersions.IndexV4; - public static readonly string AuthenticationTypeDefault = "internal"; - public const bool SkipIndexScanOnReadsDefault = false; public const long StreamExistenceFilterSizeDefault = 256_000_000;