Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
feb4a62
feat(auth): support built-in OAuth methods
yordis Jul 15, 2026
074d2a9
fix(auth): honor configured authentication methods
yordis Jul 15, 2026
6f30bb9
feat(auth): complete browser OAuth sign-in
yordis Jul 15, 2026
881dfbf
fix(auth): harden OAuth browser sign-in
yordis Jul 15, 2026
b115fc6
fix(auth): preserve OAuth callback intent
yordis Jul 15, 2026
17fa118
fix(auth): align OAuth browser flow with enabled methods
yordis Jul 15, 2026
8d06615
fix(auth): avoid blocking signed-in OAuth recovery
yordis Jul 15, 2026
eaef68e
fix(auth): reject colliding authentication plugin names
yordis Jul 15, 2026
e5ef038
fix(auth): require scopes for OAuth browser flow
yordis Jul 15, 2026
23c663e
fix(auth): preserve certificate authentication with OAuth
yordis Jul 15, 2026
2354293
fix(auth): keep certificate users available with OAuth
yordis Jul 15, 2026
4811929
fix(auth): hide password form without password method
yordis Jul 15, 2026
80237c6
fix(auth): keep OAuth user management visible
yordis Jul 15, 2026
1b09d8b
fix(auth): keep OAuth browser flow reachable
yordis Jul 15, 2026
5231e52
fix(auth): preserve OAuth browser callback routing
yordis Jul 15, 2026
da592ae
fix(auth): avoid disabled UI OAuth redirects
yordis Jul 15, 2026
5bfc10b
fix(auth): bind grouped authentication options
yordis Jul 15, 2026
61e16e7
fix(auth): protect OAuth bearer routing
yordis Jul 15, 2026
dfed5b0
fix(auth): stop advertising legacy auth method
yordis Jul 15, 2026
ffbe9e8
fix(auth): validate OAuth callback tokens
yordis Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
<PackageVersion Include="Microsoft.Extensions.Diagnostics.Testing" Version="10.7.0" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.9" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.19.2" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.19.2" />
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Mono.Posix.NETStandard" Version="1.0.0" />
Expand Down
54 changes: 42 additions & 12 deletions src/EventStore.ClusterNode/ClusterVNodeHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -246,10 +247,14 @@ AuthenticationProviderFactory GetAuthenticationProviderFactory()
return new AuthenticationProviderFactory(_ => new PassthroughAuthenticationProviderFactory());
}

var authenticationTypeToPlugin = new Dictionary<string, AuthenticationProviderFactory> {
var authenticationMethodFactories = new Dictionary<string, AuthenticationProviderFactory> {
{
"internal", new AuthenticationProviderFactory(components =>
AuthenticationMethodNames.Password, new AuthenticationProviderFactory(components =>
new InternalAuthenticationProviderFactory(components, _options.DefaultUser))
},
{
AuthenticationMethodNames.OAuth, new AuthenticationProviderFactory(_ =>
new OAuthAuthenticationProviderFactory(_options.Auth.OAuth))
}
};

Expand All @@ -261,24 +266,49 @@ 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)
{
Log.Error(ex, "Error loading authentication plugin.");
}
}

return authenticationTypeToPlugin.TryGetValue(_options.Auth.AuthenticationType.ToLowerInvariant(),
out var factory)
Comment thread
yordis marked this conversation as resolved.
? 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)));
}
Comment thread
cursor[bot] marked this conversation as resolved.

return new CompositeAuthenticationProviderFactory(factories);
});
}

static ClusterVNodeOptions LoadSubsystemsPlugins(PluginLoader pluginLoader, ClusterVNodeOptions options)
Expand Down
58 changes: 53 additions & 5 deletions src/EventStore.ClusterNode/Components/Pages/SignIn.razor
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,34 @@
<p class="mt-3 text-sm leading-6 text-es-muted">This node is running with insecure access, so there is no credential exchange to perform.</p>
<a class="mt-6 inline-flex rounded-2xl bg-es-ink px-5 py-3 text-sm font-black text-white shadow-lg shadow-es-ink/15 transition hover:bg-es-green" href="@Destination">Continue to UI</a>
}
else if (!Authentication.SupportsBasic)
else if (!Authentication.SupportsBasic && !Authentication.SupportsOAuthBrowserFlow)
{
<StatusBadge Label="@Authentication.TypeLabel" Tone="warn" />
<h2 class="mt-4 text-3xl font-black tracking-tight text-es-ink">Continue with the configured provider.</h2>
<p class="mt-3 text-sm leading-6 text-es-muted">This node does not advertise Basic authentication, so sign-in is delegated to the configured browser authentication flow.</p>
<button class="mt-6 rounded-2xl bg-es-ink px-5 py-3 text-sm font-black text-white shadow-lg shadow-es-ink/15 transition hover:bg-es-green" type="button" data-ui-oauth-signin data-ui-oauth-return="@Destination" data-ui-oauth-type="@Authentication.Type" data-ui-oauth-properties="@Authentication.PropertiesJson">Continue</button>
<p class="mt-4 hidden rounded-2xl border border-red-200 bg-red-50 p-4 text-sm font-bold text-red-800" data-ui-oauth-status></p>
<h2 class="mt-4 text-3xl font-black tracking-tight text-es-ink">Sign-in needs more configuration.</h2>
<p class="mt-3 text-sm leading-6 text-es-muted">This node does not advertise Basic authentication or a browser OAuth flow.</p>
@if (!string.IsNullOrWhiteSpace(Message))
{
<p class="mt-4 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm font-bold text-red-800">@Message</p>
}
}
else
{
@if (Authentication.SupportsOAuthBrowserFlow)
{
<div class="mb-6 rounded-[1.5rem] border border-es-ink/10 bg-es-cloud/70 p-5">
<h2 class="text-2xl font-black tracking-tight text-es-ink">Continue with OAuth</h2>
<p class="mt-2 text-sm leading-6 text-es-muted">Use the configured browser sign-in flow for this node.</p>
<button class="mt-4 rounded-2xl bg-es-ink px-5 py-3 text-sm font-black text-white shadow-lg shadow-es-ink/15 transition hover:bg-es-green" type="button" data-ui-oauth-signin data-ui-oauth-return="@Destination" data-ui-oauth-type="@Authentication.Type" data-ui-oauth-properties="@Authentication.PropertiesJson">Continue with OAuth</button>
@if (!string.IsNullOrWhiteSpace(Message))
{
<p class="mt-4 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm font-bold text-red-800">@Message</p>
}
<p class="mt-4 hidden rounded-2xl border border-red-200 bg-red-50 p-4 text-sm font-bold text-red-800" data-ui-oauth-status></p>
</div>
}

@if (Authentication.SupportsBasic)
{
<EditForm Model="Input" FormName="ui-signin" OnValidSubmit="SignInUser">
<AntiforgeryToken />
<DataAnnotationsValidator />
Expand Down Expand Up @@ -74,6 +92,7 @@
<a class="rounded-2xl border border-es-ink/10 bg-white px-5 py-3 text-sm font-black text-es-ink transition hover:border-es-green/30 hover:text-es-forest" href="/ui">Back to UI</a>
</div>
</EditForm>
}
}
</article>
</section>
Expand All @@ -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() {
Expand All @@ -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)}";
}

Expand All @@ -114,26 +147,41 @@
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;
}

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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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(),
Expand Down
Loading
Loading