Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 26 additions & 2 deletions src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public static bool IsAuthorizedRequest(HttpContext ctx, GatewayConfig config, bo
if (operatorAccounts is not null)
{
var token = GatewaySecurity.GetToken(ctx, config.Security.AllowQueryStringToken);
if (!string.IsNullOrWhiteSpace(token) && operatorAccounts.TryAuthenticateToken(token, out _))
if (!string.IsNullOrWhiteSpace(token) && TryAuthenticateAccountToken(ctx, operatorAccounts, token, out _))
return true;
}
}
Expand Down Expand Up @@ -161,7 +161,7 @@ public static OperatorAuthorizationResult AuthorizeOperatorRequest(
if (IsAllowedAuthMode(policy, OrganizationAuthModeNames.AccountToken) &&
!string.IsNullOrWhiteSpace(token) &&
operatorAccounts is not null &&
operatorAccounts.TryAuthenticateToken(token, out var accountIdentity))
TryAuthenticateAccountToken(ctx, operatorAccounts, token, out var accountIdentity))
{
return new OperatorAuthorizationResult(
true,
Expand Down Expand Up @@ -203,6 +203,30 @@ operatorAccounts is not null &&
IsBootstrapAdmin: false);
}

private sealed record AccountTokenVerification(string Token, bool Succeeded, OperatorIdentitySnapshot? Identity);

// Token verification is deliberately slow (PBKDF2) and serialized inside OperatorAccountService, and one
// request can pass through several checks (authentication, role, identity). Verify each request's token
// once and reuse the outcome; revocation still applies from the next request.
private static bool TryAuthenticateAccountToken(
HttpContext ctx,
OperatorAccountService operatorAccounts,
string token,
out OperatorIdentitySnapshot? identity)
{
if (ctx.Items.TryGetValue(typeof(AccountTokenVerification), out var cached) &&
cached is AccountTokenVerification previous &&
string.Equals(previous.Token, token, StringComparison.Ordinal))
{
identity = previous.Identity;
return previous.Succeeded;
}

var succeeded = operatorAccounts.TryAuthenticateToken(token, out identity);
ctx.Items[typeof(AccountTokenVerification)] = new AccountTokenVerification(token, succeeded, identity);
return succeeded;
}

public static bool TrySetMaxRequestBodySize(HttpContext ctx, long maxBytes)
{
var feature = ctx.Features.Get<IHttpMaxRequestBodySizeFeature>();
Expand Down
81 changes: 81 additions & 0 deletions src/OpenClaw.Tests/EndpointHelpersAuthenticationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using OpenClaw.Core.Models;
using OpenClaw.Gateway;
using OpenClaw.Gateway.Bootstrap;
using OpenClaw.Gateway.Endpoints;
using Xunit;

namespace OpenClaw.Tests;

public sealed class EndpointHelpersAuthenticationTests : IDisposable
{
private readonly string _storagePath = Path.Combine(Path.GetTempPath(), "openclaw-endpoint-auth-tests", Guid.NewGuid().ToString("N"));
Comment thread
Telli marked this conversation as resolved.

public void Dispose()
{
try { Directory.Delete(_storagePath, recursive: true); }
catch { }
Comment thread
Telli marked this conversation as resolved.
Comment thread
Telli marked this conversation as resolved.
}

[Fact]
public void AccountToken_WhenCheckedTwiceInOneRequest_ShouldBeVerifiedOnce()
{
var (startup, services, accounts, accountId, token) = CreateOperatorToken();
var ctx = CreateRequest(services, token.Token);

Assert.True(EndpointHelpers.IsAuthorizedRequest(ctx, startup.Config, startup.IsNonLoopbackBind));

// Revoking after the first check makes a second verification observable: a re-run of the slow
// token check would now fail, while a reused result for this request still succeeds.
Assert.True(accounts.RevokeToken(accountId, token.TokenInfo!.Id));
var auth = EndpointHelpers.AuthorizeOperatorRequest(ctx, startup, services.GetRequiredService<BrowserSessionAuthService>(), requireCsrf: false);

Assert.True(auth.IsAuthorized);
Assert.Equal(accountId, auth.AccountId);
}

[Fact]
public void AccountToken_WhenRevokedBeforeNextRequest_ShouldBeRejected()
{
var (startup, services, accounts, accountId, token) = CreateOperatorToken();
Assert.True(EndpointHelpers.IsAuthorizedRequest(CreateRequest(services, token.Token), startup.Config, startup.IsNonLoopbackBind));

Assert.True(accounts.RevokeToken(accountId, token.TokenInfo!.Id));

Assert.False(EndpointHelpers.IsAuthorizedRequest(CreateRequest(services, token.Token), startup.Config, startup.IsNonLoopbackBind));
}

private (GatewayStartupContext Startup, ServiceProvider Services, OperatorAccountService Accounts, string AccountId, OperatorAccountTokenCreateResponse Token) CreateOperatorToken()
{
var config = new GatewayConfig { AuthToken = "bootstrap-token" };
var startup = new GatewayStartupContext
{
Config = config,
RuntimeState = RuntimeModeResolver.Resolve(config.Runtime, dynamicCodeSupported: true),
IsNonLoopbackBind = true
};
var accounts = new OperatorAccountService(_storagePath, NullLogger<OperatorAccountService>.Instance);
var account = accounts.Create(new OperatorAccountCreateRequest
{
Username = "memo-operator",
Password = "P@ssw0rd123!",
Role = OperatorRoleNames.Operator
});
var token = accounts.CreateToken(account.Id, new OperatorAccountTokenCreateRequest { Label = "memo" })!;

var services = new ServiceCollection();
services.AddSingleton(new BrowserSessionAuthService(config));
services.AddSingleton(accounts);
services.AddSingleton(new OrganizationPolicyService(_storagePath, NullLogger<OrganizationPolicyService>.Instance));
return (startup, services.BuildServiceProvider(), accounts, account.Id, token);
}

private static DefaultHttpContext CreateRequest(IServiceProvider services, string token)
{
var ctx = new DefaultHttpContext { RequestServices = services };
ctx.Request.Headers.Authorization = $"Bearer {token}";
return ctx;
}
}
Loading