diff --git a/.editorconfig b/.editorconfig
index 7cfebaa..f4ee866 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -159,14 +159,8 @@ stylecop.documentation.xmlHeader = false
dotnet_diagnostic.SA1101.severity = none # Prefix local calls with this
dotnet_diagnostic.SA1309.severity = none # Field names must not begin with underscore
-[**/Models/*.cs]
-dotnet_diagnostic.CS8618.severity = none # Non-nullable property must contain a non-null value when exiting constructor
-
-
-# C++ Files
-[*.{cpp,h,in}]
-curly_bracket_next_line = true
-indent_brace_style = Allman
+# xunit Analyzers
+dotnet_diagnostic.xUnit1051.severity = none # Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive
# Xml project files
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}]
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..f5435e7
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,59 @@
+name: Release
+
+on:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: write
+
+jobs:
+ nuget:
+ name: dotnet
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - id: tag
+ name: Determine tag
+ run: |
+ version="$(head -n 1 src/Synadia.AuthCallout/version.txt)"
+ ref_name="v$version"
+ create=true
+ if [ "$(git ls-remote origin "refs/tags/$ref_name" | wc -l)" = "1" ]; then
+ create=false
+ fi
+
+ echo "version=$version" | tee -a "$GITHUB_OUTPUT"
+ echo "ref-name=$ref_name" | tee -a "$GITHUB_OUTPUT"
+ echo "create=$create" | tee -a "$GITHUB_OUTPUT"
+
+ - if: ${{ fromJSON(steps.tag.outputs.create) }}
+ name: Setup dotnet
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.x
+ 9.x
+
+ - if: ${{ fromJSON(steps.tag.outputs.create) }}
+ name: Pack
+ # https://learn.microsoft.com/en-us/nuget/create-packages/symbol-packages-snupkg
+ # https://devblogs.microsoft.com/dotnet/producing-packages-with-source-link/
+ run: dotnet pack -c Release -o dist -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg -p:ContinuousIntegrationBuild=true
+
+ - if: ${{ fromJSON(steps.tag.outputs.create) }}
+ name: Push
+ run: |
+ cd dist
+ ls -lh
+ # this should upload snupkgs in the same folder
+ dotnet nuget push *.nupkg -s https://api.nuget.org/v3/index.json -k "${{ secrets.NUGET_API_KEY }}" --skip-duplicate
+
+ - if: ${{ fromJSON(steps.tag.outputs.create) }}
+ name: Tag
+ run: |
+ git tag "${{ steps.tag.outputs.ref-name }}"
+ git push origin "${{ steps.tag.outputs.ref-name }}"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 88afdb3..e89abcc 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -44,6 +44,11 @@ jobs:
- name: Check nats-server
run: nats-server -v
+ - name: Setup Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: '1.x'
+
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
@@ -59,3 +64,12 @@ jobs:
- name: Test
run: dotnet test --no-build --logger:"console;verbosity=normal"
+
+ - name: Compat Test
+ env:
+ X_COMPAT_EXE: bin/Debug/net8.0/compat
+ run: |
+ cd tests/compat
+ dotnet build
+ go test -v
+
diff --git a/README.md b/README.md
index 586bdb8..0fa6332 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,23 @@
# Callout .NET
-## ⚠️ EARLY DEVELOPERS PREVIEW ⚠️
-
[](https://www.apache.org/licenses/LICENSE-2.0)
[](https://www.nuget.org/packages/Synadia.AuthCallout)
[](https://github.com/synadia-io/callout.net/actions/workflows/test.yml?query=branch%3Amain)
+### Preview
+
+This is a preview version of the library. The API is subject to change.
+The library is not yet ready for production use.
+
+> [!CAUTION]
+> ### Important Disclaimer
+>
+> This repository provides functionality built on top of NATS JWT APIs using .NET.
+> However, at this time NATS JWT .NET is _not_ a supported API.
+> Use at your own risk.
+>
+> See also [NATS JWT .NET](https://github.com/nats-io/jwt.net) library for more information.
+
This library implements a small framework for writing AuthCallout services for NATS.
See also the [Go implementation](https://github.com/synadia-io/callout.go) where this codebase is based on.
diff --git a/callout.net.sln b/callout.net.sln
index cbcb1a8..28caa2c 100644
--- a/callout.net.sln
+++ b/callout.net.sln
@@ -32,8 +32,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
ProjectSection(SolutionItems) = preProject
.github\workflows\format.yml = .github\workflows\format.yml
.github\workflows\test.yml = .github\workflows\test.yml
+ .github\workflows\release.yml = .github\workflows\release.yml
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "compat", "tests\compat\compat.csproj", "{25EFB419-55B6-4BFB-BF03-F5EB1C0346AF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -47,6 +50,7 @@ Global
{59E78136-1175-4385-BAE5-97D1EDB150D9} = {242B9172-5DAD-4B54-80C4-C34D36CA9302}
{F6DE750B-FC14-4CE5-A12E-E3E3F791E3C2} = {E6241CB5-ECE1-4593-BAE1-7752249A373E}
{A0438527-3F3D-4CC5-B422-C643C5EE7976} = {F223C628-0627-4C07-998D-DD74B74379C2}
+ {25EFB419-55B6-4BFB-BF03-F5EB1C0346AF} = {E6241CB5-ECE1-4593-BAE1-7752249A373E}
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{859C0248-9DED-4290-9F56-641D572A19A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -61,5 +65,9 @@ Global
{F6DE750B-FC14-4CE5-A12E-E3E3F791E3C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F6DE750B-FC14-4CE5-A12E-E3E3F791E3C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F6DE750B-FC14-4CE5-A12E-E3E3F791E3C2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {25EFB419-55B6-4BFB-BF03-F5EB1C0346AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {25EFB419-55B6-4BFB-BF03-F5EB1C0346AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {25EFB419-55B6-4BFB-BF03-F5EB1C0346AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {25EFB419-55B6-4BFB-BF03-F5EB1C0346AF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/examples/Example.AuthService/Example.AuthService.csproj b/examples/Example.AuthService/Example.AuthService.csproj
index 2712528..d6897cb 100644
--- a/examples/Example.AuthService/Example.AuthService.csproj
+++ b/examples/Example.AuthService/Example.AuthService.csproj
@@ -1,16 +1,17 @@
-
- Exe
- net8.0
- enable
- enable
-
- true
-
+
+ Exe
+ net8.0
+ enable
+ enable
+ true
+ false
+ true
+
-
-
-
+
+
+
diff --git a/examples/Example.AuthService/Program.cs b/examples/Example.AuthService/Program.cs
index 854cf3d..99f6c11 100644
--- a/examples/Example.AuthService/Program.cs
+++ b/examples/Example.AuthService/Program.cs
@@ -22,7 +22,7 @@
await using var connection = new NatsConnection(new NatsOpts { AuthOpts = new NatsAuthOpts { CredsFile = creds } });
-ValueTask Authorizer(NatsAuthorizationRequest r)
+ValueTask Authorizer(NatsAuthorizationRequest r, CancellationToken cancellationToken)
{
NatsUserClaims user = jwt.NewUserClaims(r.UserNKey);
@@ -34,14 +34,14 @@ ValueTask Authorizer(NatsAuthorizationRequest r)
return ValueTask.FromResult(jwt.EncodeUserClaims(user, akp));
}
-ValueTask ResponseSigner(NatsAuthorizationResponseClaims r)
+ValueTask ResponseSigner(NatsAuthorizationResponseClaims r, CancellationToken cancellationToken)
{
return ValueTask.FromResult(jwt.EncodeAuthorizationResponseClaims(r, ckp));
}
var opts = new NatsAuthServiceOpts(Authorizer, ResponseSigner)
{
- ErrorHandler = e =>
+ ErrorHandler = (e, ct) =>
{
Console.WriteLine($"ERROR: {e}");
return default;
diff --git a/src/Synadia.AuthCallout/INatsAuthService.cs b/src/Synadia.AuthCallout/INatsAuthService.cs
index c26adc6..f5c4f26 100644
--- a/src/Synadia.AuthCallout/INatsAuthService.cs
+++ b/src/Synadia.AuthCallout/INatsAuthService.cs
@@ -16,14 +16,16 @@ public interface INatsAuthService : IAsyncDisposable
/// Starts the NatsAuthService asynchronously by initializing the service and setting up the necessary endpoints
/// for handling authentication requests.
///
+ /// A cancellation token to observe while waiting for the operation to complete.
/// A task that represents the asynchronous start operation.
- ValueTask StartAsync();
+ ValueTask StartAsync(CancellationToken cancellationToken = default);
///
/// Processes an incoming request asynchronously by decoding the JWT, authorizing the request, and optionally encrypting
/// the response before returning it.
///
/// The incoming request message containing the data to process.
+ /// A cancellation token to observe while waiting for the operation to complete.
/// A task that represents the asynchronous operation, containing the processed response as a byte array.
- ValueTask ProcessRequestAsync(NatsSvcMsg msg);
+ ValueTask ProcessRequestAsync(NatsSvcMsg msg, CancellationToken cancellationToken = default);
}
diff --git a/src/Synadia.AuthCallout/NatsAuthService.cs b/src/Synadia.AuthCallout/NatsAuthService.cs
index b128121..029f69c 100644
--- a/src/Synadia.AuthCallout/NatsAuthService.cs
+++ b/src/Synadia.AuthCallout/NatsAuthService.cs
@@ -40,44 +40,43 @@ public NatsAuthService(INatsSvcContext svc, NatsAuthServiceOpts opts)
}
///
- public async ValueTask StartAsync()
+ public async ValueTask StartAsync(CancellationToken cancellationToken = default)
{
- _server = await _svc.AddServiceAsync("auth-server", "1.0.0");
+ _server = await _svc.AddServiceAsync("auth-server", "1.0.0", cancellationToken: cancellationToken);
await _server.AddEndpointAsync(
handler: async msg =>
{
try
{
- byte[] token = await ProcessRequestAsync(msg);
- if (token.Length == 0)
- {
- return;
- }
-
- await msg.ReplyAsync(token);
+ byte[] token = await ProcessRequestAsync(msg, cancellationToken);
+ await msg.ReplyAsync(token, cancellationToken: cancellationToken);
+ }
+ catch (NatsAuthServiceAuthException e)
+ {
+ _logger.LogInformation(e, "Auth error");
+ await CallErrorHandlerAsync(e, cancellationToken);
+ await msg.ReplyErrorAsync(401, "Unauthorized", cancellationToken: cancellationToken);
+ }
+ catch (NatsAuthServiceException e)
+ {
+ _logger.LogWarning(e, "Service error");
+ await CallErrorHandlerAsync(e, cancellationToken);
+ await msg.ReplyErrorAsync(400, e.Message, cancellationToken: cancellationToken);
}
catch (Exception e)
{
- _logger.LogError(e, "Auth error");
- if (_opts.ErrorHandler is { } errorHandler)
- {
- try
- {
- await errorHandler(e);
- }
- catch (Exception e2)
- {
- _logger.LogError(e2, "Auth error handler");
- }
- }
+ _logger.LogError(e, "Generic error");
+ await CallErrorHandlerAsync(e, cancellationToken);
+ await msg.ReplyErrorAsync(400, e.Message, cancellationToken: cancellationToken);
}
},
name: "auth-request-handler",
- subject: SysRequestUserAuthSubj);
+ subject: SysRequestUserAuthSubj,
+ cancellationToken: cancellationToken);
}
///
- public async ValueTask ProcessRequestAsync(NatsSvcMsg msg)
+ public async ValueTask ProcessRequestAsync(NatsSvcMsg msg, CancellationToken cancellationToken = default)
{
var (isEncrypted, req) = DecodeJwt(msg);
var res = new NatsAuthorizationResponseClaims
@@ -86,29 +85,16 @@ public async ValueTask ProcessRequestAsync(NatsSvcMsg msg)
Audience = req.NatsServer.Id,
};
- string user = await _opts.Authorizer(req);
+ string user = await _opts.Authorizer(req, cancellationToken);
if (user == string.Empty)
{
- _logger.LogWarning("Error authorizing: authorizer didn't generate a JWT: {User}", req.UserNKey);
- if (_opts.ErrorHandler is { } errorHandler)
- {
- try
- {
- await errorHandler(new NatsAuthServiceAuthException("Error authorizing: authorizer didn't generate a JWT", req.UserNKey));
- }
- catch (Exception e2)
- {
- _logger.LogError(e2, "Auth error handler");
- }
- }
-
- return [];
+ throw new NatsAuthServiceAuthException("Error authorizing: authorizer didn't generate a JWT");
}
res.AuthorizationResponse.Jwt = user;
- string tokenString = await _opts.ResponseSigner(res);
+ string tokenString = await _opts.ResponseSigner(res, cancellationToken);
byte[] token = Encoding.ASCII.GetBytes(tokenString);
if (isEncrypted)
@@ -133,6 +119,21 @@ public async ValueTask DisposeAsync()
}
}
+ private async Task CallErrorHandlerAsync(Exception exception, CancellationToken cancellationToken)
+ {
+ if (_opts.ErrorHandler is { } errorHandler)
+ {
+ try
+ {
+ await errorHandler(exception, cancellationToken);
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Auth error handler");
+ }
+ }
+ }
+
private (bool IsEncrypted, NatsAuthorizationRequest Request) DecodeJwt(NatsSvcMsg msg)
{
byte[] data = msg.Data!;
@@ -144,20 +145,26 @@ public async ValueTask DisposeAsync()
}
bool isEncrypted = !jwt.StartsWith("eyJ0");
- if (isEncrypted)
+
+ if (isEncrypted && _opts.EncryptionKey == null)
{
- if (_opts.EncryptionKey == null)
- {
- throw new NatsAuthServiceException("No encryption key found");
- }
+ throw new NatsAuthServiceException("Bad request: encryption mismatch: payload is encrypted");
+ }
+
+ if (!isEncrypted && _opts.EncryptionKey != null)
+ {
+ throw new NatsAuthServiceException("Bad request: encryption mismatch: payload is not encrypted");
+ }
+ if (isEncrypted)
+ {
if (msg.Headers == null)
{
throw new NatsAuthServiceException("No encryption headers found");
}
var serverKey = msg.Headers[NatsServerXKeyHeader];
- byte[] open = _opts.EncryptionKey.Open(data, serverKey);
+ byte[] open = _opts.EncryptionKey!.Open(data, serverKey);
jwt = Encoding.ASCII.GetString(open);
}
@@ -165,17 +172,17 @@ public async ValueTask DisposeAsync()
if (!arc.Issuer.StartsWith("N"))
{
- throw new NatsAuthServiceException($"bad request: expected server: {arc.Issuer}");
+ throw new NatsAuthServiceException($"Bad request: expected server: {arc.Issuer}");
}
if (arc.Issuer != arc.AuthorizationRequest.NatsServer.Id)
{
- throw new NatsAuthServiceException($"bad request: issuers don't match: {arc.Issuer} != {arc.AuthorizationRequest.NatsServer.Id}");
+ throw new NatsAuthServiceException($"Bad request: issuers don't match: {arc.Issuer} != {arc.AuthorizationRequest.NatsServer.Id}");
}
if (arc.Audience != ExpectedAudience)
{
- throw new NatsAuthServiceException($"bad request: unexpected audience: {arc.Audience}");
+ throw new NatsAuthServiceException($"Bad request: unexpected audience: {arc.Audience}");
}
return (isEncrypted, arc.AuthorizationRequest);
diff --git a/src/Synadia.AuthCallout/NatsAuthServiceAuthException.cs b/src/Synadia.AuthCallout/NatsAuthServiceAuthException.cs
index 5742fe3..4df8524 100644
--- a/src/Synadia.AuthCallout/NatsAuthServiceAuthException.cs
+++ b/src/Synadia.AuthCallout/NatsAuthServiceAuthException.cs
@@ -14,16 +14,8 @@ public class NatsAuthServiceAuthException : Exception
/// Creates an instance.
///
/// Error message.
- /// Related user ID.
- public NatsAuthServiceAuthException(string message, string user)
+ public NatsAuthServiceAuthException(string message)
: base(message)
{
- User = user;
}
-
- ///
- /// Gets the user identifier associated with the exception.
- /// Represents the user-related context involved in the authentication process where the exception was thrown.
- ///
- public string User { get; }
}
diff --git a/src/Synadia.AuthCallout/NatsAuthServiceOpts.cs b/src/Synadia.AuthCallout/NatsAuthServiceOpts.cs
index e3d6875..7d08991 100644
--- a/src/Synadia.AuthCallout/NatsAuthServiceOpts.cs
+++ b/src/Synadia.AuthCallout/NatsAuthServiceOpts.cs
@@ -16,7 +16,9 @@ public record NatsAuthServiceOpts
///
/// Authorizer callback.
/// Response signer callback.
- public NatsAuthServiceOpts(Func> authorizer, Func> responseSigner)
+ public NatsAuthServiceOpts(
+ Func> authorizer,
+ Func> responseSigner)
{
Authorizer = authorizer;
ResponseSigner = responseSigner;
@@ -32,15 +34,15 @@ public NatsAuthServiceOpts(Func> aut
///
/// Gets a function that processes authorization request and issues user JWTs.
///
- public Func> Authorizer { get; init; }
+ public Func> Authorizer { get; init; }
///
/// Gets a function that performs the signing of the .
///
- public Func> ResponseSigner { get; init; }
+ public Func> ResponseSigner { get; init; }
///
/// Gets a delegate for handling exceptions that occur during authorization.
///
- public Func? ErrorHandler { get; init; }
+ public Func? ErrorHandler { get; init; }
}
diff --git a/src/Synadia.AuthCallout/Synadia.AuthCallout.csproj b/src/Synadia.AuthCallout/Synadia.AuthCallout.csproj
index 3d7fe23..31a3db8 100644
--- a/src/Synadia.AuthCallout/Synadia.AuthCallout.csproj
+++ b/src/Synadia.AuthCallout/Synadia.AuthCallout.csproj
@@ -1,16 +1,18 @@
-
- netstandard2.0;net8.0;net9.0
- enable
- enable
- latest
- true
-
+
+ netstandard2.0;net8.0;net9.0
+ enable
+ enable
+ latest
+ nats,authcallout
+ Synadia NATS Auth Callout for .NET
+ true
+
-
-
-
-
+
+
+
+
diff --git a/src/Synadia.AuthCallout/version.txt b/src/Synadia.AuthCallout/version.txt
index 5fd7619..64e1c09 100644
--- a/src/Synadia.AuthCallout/version.txt
+++ b/src/Synadia.AuthCallout/version.txt
@@ -1 +1 @@
-1.0.0-preview.1
+1.0.0-preview.2
diff --git a/tests/Synadia.AuthCallout.Tests/AuthServiceTest.cs b/tests/Synadia.AuthCallout.Tests/AuthServiceTest.cs
index 80336ef..7504a86 100644
--- a/tests/Synadia.AuthCallout.Tests/AuthServiceTest.cs
+++ b/tests/Synadia.AuthCallout.Tests/AuthServiceTest.cs
@@ -7,7 +7,6 @@
using NATS.Jwt.Models;
using NATS.Net;
using NATS.NKeys;
-using Xunit.Abstractions;
namespace Synadia.AuthCallout.Tests;
@@ -17,7 +16,6 @@ public class AuthServiceTest(ITestOutputHelper output)
public async Task Connect_with_jwt()
{
var jwt = new NatsJwt();
-
var okp = KeyPair.CreatePair(PrefixByte.Operator);
var opk = okp.GetPublicKey();
var oc = jwt.NewOperatorClaims(opk);
@@ -90,7 +88,7 @@ public async Task Connect_with_callout()
await authNats.PingAsync();
var opts = new NatsAuthServiceOpts(
- authorizer: r =>
+ authorizer: (r, ct) =>
{
NatsUserClaims user = jwt.NewUserClaims(r.UserNKey);
user.Audience = "AUTH";
@@ -105,9 +103,9 @@ public async Task Connect_with_callout()
return ValueTask.FromResult(jwt.EncodeUserClaims(user, akp));
},
- responseSigner: r => ValueTask.FromResult(jwt.EncodeAuthorizationResponseClaims(r, akp)))
+ responseSigner: (r, ct) => ValueTask.FromResult(jwt.EncodeAuthorizationResponseClaims(r, akp)))
{
- ErrorHandler = e =>
+ ErrorHandler = (e, ct) =>
{
output.WriteLine($"SERVICE ERROR: {e}");
return default;
@@ -164,7 +162,7 @@ public async Task Connect_with_callout_with_xkey()
await authNats.PingAsync();
var opts = new NatsAuthServiceOpts(
- authorizer: r =>
+ authorizer: (r, ct) =>
{
NatsUserClaims user = jwt.NewUserClaims(r.UserNKey);
user.Audience = "AUTH";
@@ -179,10 +177,10 @@ public async Task Connect_with_callout_with_xkey()
return ValueTask.FromResult(jwt.EncodeUserClaims(user, akp));
},
- responseSigner: r => ValueTask.FromResult(jwt.EncodeAuthorizationResponseClaims(r, akp)))
+ responseSigner: (r, ct) => ValueTask.FromResult(jwt.EncodeAuthorizationResponseClaims(r, akp)))
{
EncryptionKey = xkp,
- ErrorHandler = e =>
+ ErrorHandler = (e, ct) =>
{
output.WriteLine($"SERVICE ERROR: {e}");
return default;
diff --git a/tests/Synadia.AuthCallout.Tests/Synadia.AuthCallout.Tests.csproj b/tests/Synadia.AuthCallout.Tests/Synadia.AuthCallout.Tests.csproj
index 9966d93..2fb36fc 100644
--- a/tests/Synadia.AuthCallout.Tests/Synadia.AuthCallout.Tests.csproj
+++ b/tests/Synadia.AuthCallout.Tests/Synadia.AuthCallout.Tests.csproj
@@ -1,27 +1,32 @@
-
- net8.0
- enable
- enable
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
- false
- true
-
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
+
+
+
diff --git a/tests/compat/.compat.root b/tests/compat/.compat.root
new file mode 100644
index 0000000..1d9ca61
--- /dev/null
+++ b/tests/compat/.compat.root
@@ -0,0 +1 @@
+This file is used to determine the root of the compat tests
diff --git a/tests/compat/ChildProcessTracker.cs b/tests/compat/ChildProcessTracker.cs
new file mode 100644
index 0000000..fe98799
--- /dev/null
+++ b/tests/compat/ChildProcessTracker.cs
@@ -0,0 +1,174 @@
+// Copyright (c) Synadia Communications, Inc. All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+#pragma warning disable
+
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.InteropServices;
+
+namespace Compat;
+
+// Credits: adapted from https://stackoverflow.com/questions/3342941/kill-child-process-when-parent-process-is-killed/37034966#37034966
+
+///
+/// Allows processes to be automatically killed if this parent process unexpectedly quits.
+/// This feature requires Windows 8 or greater. On Windows 7, nothing is done.
+/// References:
+/// https://stackoverflow.com/a/4657392/386091
+/// https://stackoverflow.com/a/9164742/386091.
+#pragma warning disable SA1204
+#pragma warning disable SA1129
+#pragma warning disable SA1201
+#pragma warning disable SA1117
+#pragma warning disable SA1400
+#pragma warning disable SA1311
+#pragma warning disable SA1308
+#pragma warning disable SA1413
+#pragma warning disable SA1121
+public static class ChildProcessTracker
+{
+ ///
+ /// Add the process to be tracked. If our current process is killed, the child processes
+ /// that we are tracking will be automatically killed, too. If the child process terminates
+ /// first, that's fine, too.
+ ///
+ public static void AddProcess(Process process)
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return;
+ }
+
+ if (s_jobHandle != IntPtr.Zero)
+ {
+ var success = AssignProcessToJobObject(s_jobHandle, process.Handle);
+ if (!success && !process.HasExited)
+ {
+ throw new Win32Exception();
+ }
+ }
+ }
+
+ [RequiresDynamicCode("Calls System.Runtime.InteropServices.Marshal.SizeOf(Type)")]
+ static ChildProcessTracker()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return;
+ }
+
+ // This feature requires Windows 8 or later. To support Windows 7, requires
+ // registry settings to be added if you are using Visual Studio plus an
+ // app.manifest change.
+ // https://stackoverflow.com/a/4232259/386091
+ // https://stackoverflow.com/a/9507862/386091
+ if (Environment.OSVersion.Version < new Version(6, 2))
+ {
+ return;
+ }
+
+ // The job name is optional (and can be null), but it helps with diagnostics.
+ // If it's not null, it has to be unique. Use SysInternals' Handle command-line
+ // utility: handle -a ChildProcessTracker
+ var jobName = "ChildProcessTracker" + Process.GetCurrentProcess().Id;
+ s_jobHandle = CreateJobObject(IntPtr.Zero, jobName);
+
+ var info = new JOBOBJECT_BASIC_LIMIT_INFORMATION();
+
+ // This is the key flag. When our process is killed, Windows will automatically
+ // close the job handle, and when that happens, we want the child processes to
+ // be killed, too.
+ info.LimitFlags = JOBOBJECTLIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+
+ var extendedInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
+ extendedInfo.BasicLimitInformation = info;
+
+ var length = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
+ var extendedInfoPtr = Marshal.AllocHGlobal(length);
+ try
+ {
+ Marshal.StructureToPtr(extendedInfo, extendedInfoPtr, false);
+
+ if (!SetInformationJobObject(s_jobHandle, JobObjectInfoType.ExtendedLimitInformation,
+ extendedInfoPtr, (uint)length))
+ {
+ throw new Win32Exception();
+ }
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(extendedInfoPtr);
+ }
+ }
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
+ static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string name);
+
+ [DllImport("kernel32.dll")]
+ static extern bool SetInformationJobObject(IntPtr job, JobObjectInfoType infoType,
+ IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
+
+ // Windows will automatically close any open job handles when our process terminates.
+ // This can be verified by using SysInternals' Handle utility. When the job handle
+ // is closed, the child processes will be killed.
+ private static readonly IntPtr s_jobHandle;
+}
+
+
+public enum JobObjectInfoType
+{
+ AssociateCompletionPortInformation = 7,
+ BasicLimitInformation = 2,
+ BasicUIRestrictions = 4,
+ EndOfJobTimeInformation = 6,
+ ExtendedLimitInformation = 9,
+ SecurityLimitInformation = 5,
+ GroupInformation = 11
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct JOBOBJECT_BASIC_LIMIT_INFORMATION
+{
+ public long PerProcessUserTimeLimit;
+ public long PerJobUserTimeLimit;
+ public JOBOBJECTLIMIT LimitFlags;
+ public UIntPtr MinimumWorkingSetSize;
+ public UIntPtr MaximumWorkingSetSize;
+ public uint ActiveProcessLimit;
+ public long Affinity;
+ public uint PriorityClass;
+ public uint SchedulingClass;
+}
+
+[Flags]
+public enum JOBOBJECTLIMIT : uint
+{
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct IO_COUNTERS
+{
+ public ulong ReadOperationCount;
+ public ulong WriteOperationCount;
+ public ulong OtherOperationCount;
+ public ulong ReadTransferCount;
+ public ulong WriteTransferCount;
+ public ulong OtherTransferCount;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
+{
+ public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
+ public IO_COUNTERS IoInfo;
+ public UIntPtr ProcessMemoryLimit;
+ public UIntPtr JobMemoryLimit;
+ public UIntPtr PeakProcessMemoryUsed;
+ public UIntPtr PeakJobMemoryUsed;
+}
diff --git a/tests/compat/Go.cs b/tests/compat/Go.cs
new file mode 100644
index 0000000..c8ddd6c
--- /dev/null
+++ b/tests/compat/Go.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Synadia Communications, Inc. All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+#pragma warning disable
+
+using System.Diagnostics;
+
+namespace Compat;
+
+public class Go
+{
+ private readonly string _cwd;
+ private readonly string _exe;
+
+ public Go(string cwd, string exe)
+ {
+ _cwd = cwd;
+ _exe = exe;
+ }
+
+ public int Test() => GoExe("test -v");
+
+ private int GoExe(string args)
+ {
+ var go = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "go",
+ Arguments = args,
+ WorkingDirectory = _cwd,
+ EnvironmentVariables =
+ {
+ ["X_COMPAT_EXE"] = _exe,
+ },
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ },
+ };
+
+ go.OutputDataReceived += (s, e) => { Console.WriteLine(e.Data); };
+ go.ErrorDataReceived += (s, e) => { Console.WriteLine(e.Data); };
+
+ go.Start();
+
+ ChildProcessTracker.AddProcess(go);
+
+ go.BeginErrorReadLine();
+ go.BeginOutputReadLine();
+
+ var timeout = TimeSpan.FromMinutes(5);
+
+ if (!go.WaitForExit(timeout))
+ {
+ go.Kill();
+ Console.Error.WriteLine($"Go timed out after {timeout} killing process");
+ return 4;
+ }
+
+ return go.ExitCode;
+ }
+}
diff --git a/tests/compat/NscStore.cs b/tests/compat/NscStore.cs
new file mode 100644
index 0000000..0342217
--- /dev/null
+++ b/tests/compat/NscStore.cs
@@ -0,0 +1,218 @@
+// Copyright (c) Synadia Communications, Inc. All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+#pragma warning disable
+
+using System.Text.Json.Nodes;
+using NATS.Jwt;
+using NATS.NKeys;
+
+namespace Compat;
+
+#pragma warning disable
+class NscStore
+{
+ private readonly DirectoryInfo _dir;
+ private readonly DirectoryInfo _stores;
+ private readonly DirectoryInfo _keys;
+
+ public NscStore(string dir)
+ {
+ _dir = new DirectoryInfo(dir);
+ _stores = new DirectoryInfo(Path.Combine(dir, "stores"));
+ _keys = new DirectoryInfo(Path.Combine(dir, "keys", "keys"));
+ }
+
+ public IEnumerable LoadOperators()
+ {
+ foreach (DirectoryInfo d in _stores.GetDirectories())
+ {
+ yield return NscOperator.Load(this, d);
+ }
+ }
+
+ public KeyPair? LoadKey(string? pk)
+ {
+ if (pk == null)
+ {
+ return null;
+ }
+
+ string nk = Path.Combine(_keys.FullName, pk[..1], pk[1..3], $"{pk}.nk");
+ KeyPair kp = KeyPair.FromSeed(File.ReadAllText(nk));
+ if (kp.GetPublicKey() != pk)
+ {
+ throw new Exception($"Load key error: invalid key {pk}");
+ }
+
+ return kp;
+ }
+
+ public JsonNode LoadJwtPayload(DirectoryInfo dir)
+ {
+ return LoadJwtPayload(dir.GetFiles().First(f => f.Extension == ".jwt"));
+ }
+
+ public JsonNode LoadJwtPayload(FileInfo file)
+ {
+ string jwt = file.OpenText().ReadToEnd();
+ return JsonNode.Parse(EncodingUtils.FromBase64UrlEncoded(jwt.Split('.')[1]));
+ }
+
+ public (KeyPair Issuer, KeyPair Subject) GetIssuerAndSubjectKeys(JsonNode json)
+ {
+ string iss = json["iss"].GetValue();
+ string sub = json["sub"].GetValue();
+ return (LoadKey(iss), LoadKey(sub));
+ }
+}
+
+record NscEntry
+{
+ public string Name { get; init; }
+ public KeyPair Issuer { get; init; }
+ public KeyPair Subject { get; init; }
+ public JsonNode JwtPayload { get; init; }
+}
+
+record NscOperator : NscEntry
+{
+ public static NscOperator Load(NscStore store, DirectoryInfo dir)
+ {
+ var payload = store.LoadJwtPayload(dir);
+ (KeyPair issuer, KeyPair subject) = store.GetIssuerAndSubjectKeys(payload);
+
+ var systemAccount = store.LoadKey(payload["nats"]?["system_account"]?.GetValue());
+
+ var accounts = new List();
+ foreach (DirectoryInfo d in dir.GetDirectories().First(f => f.Name == "accounts").GetDirectories())
+ {
+ accounts.Add(NscAccount.Load(store, d));
+ }
+
+ return new NscOperator
+ {
+ Name = dir.Name,
+ JwtPayload = payload,
+ Issuer = issuer,
+ Subject = subject,
+ Accounts = accounts,
+ SystemAccount = systemAccount,
+ };
+ }
+
+ public KeyPair? SystemAccount { get; init; }
+ public List Accounts { get; init; } = new();
+}
+
+record NscAccount : NscEntry
+{
+ public List Users { get; init; } = new();
+
+ public List SigningKeys { get; init; } = new();
+
+ public List AuthorizationAllowedAccounts { get; init; } = new();
+
+ public List AuthorizationAuthUsers { get; init; } = new();
+
+ public static NscAccount Load(NscStore store, DirectoryInfo dir)
+ {
+ var payload = store.LoadJwtPayload(dir);
+ (KeyPair issuer, KeyPair subject) = store.GetIssuerAndSubjectKeys(payload);
+
+ var signingKeys = new List();
+ if (payload["nats"]?["signing_keys"]?.AsArray() is { } keysArray)
+ {
+ foreach (JsonNode? jsonNode in keysArray)
+ {
+ if (jsonNode == null)
+ {
+ continue;
+ }
+
+ string? key = jsonNode.GetValue();
+ KeyPair? signingKey = store.LoadKey(key);
+ if (signingKey != null)
+ {
+ signingKeys.Add(signingKey);
+ }
+ }
+ }
+
+ var authorizationAllowedAccounts = new List();
+ if (payload["nats"]?["authorization"]?["allowed_accounts"]?.AsArray() is { } keysArray2)
+ {
+ foreach (JsonNode? jsonNode in keysArray2)
+ {
+ if (jsonNode == null)
+ {
+ continue;
+ }
+
+ string? key = jsonNode.GetValue();
+ KeyPair? signingKey = store.LoadKey(key);
+ if (signingKey != null)
+ {
+ authorizationAllowedAccounts.Add(signingKey);
+ }
+ }
+ }
+
+ var authorizationAuthUsers = new List();
+ if (payload["nats"]?["authorization"]?["auth_users"]?.AsArray() is { } keysArray3)
+ {
+ foreach (JsonNode? jsonNode in keysArray3)
+ {
+ if (jsonNode == null)
+ {
+ continue;
+ }
+
+ string? key = jsonNode.GetValue();
+ KeyPair? signingKey = store.LoadKey(key);
+ if (signingKey != null)
+ {
+ authorizationAuthUsers.Add(signingKey);
+ }
+ }
+ }
+
+ var users = new List();
+ DirectoryInfo usersDir = dir.GetDirectories().FirstOrDefault(f => f.Name == "users");
+ if (usersDir != null)
+ {
+ foreach (FileInfo j in usersDir.GetFiles().Where(f => f.Extension == ".jwt"))
+ {
+ users.Add(NscUser.Load(store, j));
+ }
+ }
+
+ return new NscAccount
+ {
+ Name = dir.Name,
+ JwtPayload = payload,
+ SigningKeys = signingKeys,
+ AuthorizationAllowedAccounts = authorizationAllowedAccounts,
+ AuthorizationAuthUsers = authorizationAuthUsers,
+ Issuer = issuer,
+ Subject = subject,
+ Users = users,
+ };
+ }
+}
+
+record NscUser : NscEntry
+{
+ public static NscUser Load(NscStore store, FileInfo file)
+ {
+ var payload = store.LoadJwtPayload(file);
+ (KeyPair issuer, KeyPair subject) = store.GetIssuerAndSubjectKeys(payload);
+ return new NscUser
+ {
+ Name = file.Name,
+ JwtPayload = payload,
+ Issuer = issuer,
+ Subject = subject,
+ };
+ }
+}
diff --git a/tests/compat/Program.cs b/tests/compat/Program.cs
new file mode 100644
index 0000000..d1b570b
--- /dev/null
+++ b/tests/compat/Program.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Synadia Communications, Inc. All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+#pragma warning disable
+
+//
+// COMPATIBILITY TESTS
+//
+// To run compatibility tests, run this application without any arguments.
+// You must have the Go SDK installed and available in your PATH.
+//
+// Alternatively, you can run the Go tests in a terminal with the following commands:
+//
+// powershell:
+// $env:X_COMPAT_EXE = "bin/Debug/net8.0/compat"
+// $env:X_COMPAT_DEBUG = 0
+//
+// sh/bash:
+// export X_COMPAT_EXE=bin/Debug/net8.0/compat
+// export X_COMPAT_DEBUG=0
+//
+// both shells:
+// cd tests/compat
+// dotnet build
+// go test -v
+//
+
+using Compat;
+
+// var store = new NscStore("C:\\Users\\mtmk\\.local\\share\\nats\\nsc");
+// var store = new NscStore("D:\\tmp\\asd\\callout_test4152748871\\nsc");
+// foreach (NscOperator op in store.LoadOperators())
+// {
+// Console.WriteLine(op);
+// foreach (NscAccount acc in op.Accounts)
+// {
+// Console.WriteLine(acc);
+// foreach (NscUser user in acc.Users)
+// {
+// Console.WriteLine(user);
+// }
+// }
+// }
+// return 1;
+
+var t = new Testing(args);
+return t.Run();
diff --git a/tests/compat/TestContext.cs b/tests/compat/TestContext.cs
new file mode 100644
index 0000000..268d0a6
--- /dev/null
+++ b/tests/compat/TestContext.cs
@@ -0,0 +1,180 @@
+// Copyright (c) Synadia Communications, Inc. All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+#pragma warning disable
+
+using System.Text.Json.Nodes;
+using NATS.Client.Core;
+using NATS.Jwt;
+using NATS.Jwt.Models;
+using NATS.NKeys;
+
+namespace Compat;
+
+public record TestContext
+{
+ public CompatVars cv { get; init; }
+ public CancellationTokenSource cts { get; init; }
+ public TaskCompletionSource tcs { get; init; }
+ public NatsConnection nt { get; init; }
+ public NatsConnection connection { get; init; }
+ public string name { get; init; }
+ public string suitName { get; init; }
+ public NatsJwt jwt { get; init; }
+ public string env { get; init; }
+
+ public IClaimsEncoder Encoder
+ {
+ get
+ {
+ if (env.StartsWith("TestDelegated"))
+ {
+ return new DelegatedClaimsEncoder(this);
+ }
+ else
+ {
+ return new BasicClaimsEncoder(this);
+ }
+ }
+ }
+
+ public string Subject(string suffix)
+ {
+ return suitName + suffix;
+ }
+}
+
+public record CompatVars
+{
+ public string SuitName { get; init; }
+ public string Name { get; init; }
+ public string Env { get; init; }
+ public string Url { get; init; }
+ public string Username { get; init; }
+ public string Password { get; init; }
+ public string Audience { get; init; }
+ public string UserInfoSubj { get; init; }
+
+ public Dictionary AccountKeys { get; init; }
+ public KeyPair? Ekp { get; init; }
+
+ public string ServiceCreds { get; init; }
+
+ public string NscDir { get; init; }
+
+ public string Dir { get; init; }
+
+ public static CompatVars FromJson(string suitName, string jsonString)
+ {
+ string[] parts = suitName.Split('/');
+ string env = parts[0];
+ string name = parts[1];
+
+ var json = JsonNode.Parse(jsonString);
+ if (json == null)
+ {
+ throw new Exception("Failed to parse JSON");
+ }
+
+ Dictionary keys = new();
+ foreach ((string? key, JsonNode? value) in json["account_keys"].AsObject())
+ {
+ if (string.IsNullOrWhiteSpace(key) || value == null) continue;
+ string seed = value["seed"]!.GetValue();
+ string pk = value["pk"]!.GetValue();
+ if (!string.IsNullOrEmpty(seed))
+ {
+ var kp = KeyPair.FromSeed(seed);
+ if (pk != kp.GetPublicKey())
+ {
+ throw new Exception("Invalid account key");
+ }
+
+ keys[key] = kp;
+ }
+ }
+
+ KeyPair? ekp = null;
+ string encryptionSeed = json["encryption_key"]!["seed"]!.GetValue();
+ string encryptionPk = json["encryption_key"]!["pk"]!.GetValue();
+ if (!string.IsNullOrEmpty(encryptionSeed))
+ {
+ ekp = KeyPair.FromSeed(encryptionSeed);
+ if (encryptionPk != ekp.GetPublicKey())
+ {
+ throw new Exception("Invalid encryption key");
+ }
+ }
+
+ return new CompatVars
+ {
+ SuitName = suitName,
+ Name = name,
+ Env = env,
+ Url = json["nats_opts"]!["urls"]!.AsArray().First().GetValue(),
+ Username = json["nats_opts"]!["user"]!.GetValue(),
+ Password = json["nats_opts"]!["password"]!.GetValue(),
+ Audience = json["audience"]!.GetValue(),
+ UserInfoSubj = json["user_info_subj"]!.GetValue(),
+ Dir = json["dir"]!.GetValue(),
+ NscDir = json["nsc_dir"]!.GetValue(),
+ ServiceCreds = json["service_creds"]!.GetValue(),
+ Ekp = ekp,
+ AccountKeys = keys,
+ };
+ }
+}
+
+public interface IClaimsEncoder
+{
+ string Encode(NatsUserClaims claims);
+ string Encode(NatsAuthorizationResponseClaims claims);
+}
+
+public class BasicClaimsEncoder : IClaimsEncoder
+{
+ private readonly TestContext _t;
+
+ public BasicClaimsEncoder(TestContext t)
+ {
+ _t = t;
+ }
+
+ public string Encode(NatsUserClaims claims)
+ {
+ return _t.jwt.EncodeUserClaims(claims, _t.cv.AccountKeys["A"]);
+ }
+
+ public string Encode(NatsAuthorizationResponseClaims claims)
+ {
+ return _t.jwt.EncodeAuthorizationResponseClaims(claims, _t.cv.AccountKeys["A"]);
+ }
+}
+
+public class DelegatedClaimsEncoder : IClaimsEncoder
+{
+ private readonly TestContext _t;
+
+ public DelegatedClaimsEncoder(TestContext t)
+ {
+ _t = t;
+ }
+
+ public string Encode(NatsUserClaims claims)
+ {
+ var store = new NscStore(_t.cv.NscDir);
+ var op = store.LoadOperators().First(o => o.Name == "O");
+ var a = op.Accounts.First(a => a.Name == "A");
+ claims.User.IssuerAccount = a.Subject.GetPublicKey();
+ return _t.jwt.EncodeUserClaims(claims, a.SigningKeys[0]);
+ }
+
+ public string Encode(NatsAuthorizationResponseClaims claims)
+ {
+ var store = new NscStore(_t.cv.NscDir);
+ var op = store.LoadOperators().First(o => o.Name == "O");
+ var c = op.Accounts.First(a => a.Name == "C");
+ claims.AuthorizationResponse.IssuerAccount = c.Subject.GetPublicKey();
+ return _t.jwt.EncodeAuthorizationResponseClaims(claims, c.SigningKeys[0]);
+ }
+}
diff --git a/tests/compat/Testing.cs b/tests/compat/Testing.cs
new file mode 100644
index 0000000..bb1c6da
--- /dev/null
+++ b/tests/compat/Testing.cs
@@ -0,0 +1,347 @@
+// Copyright (c) Synadia Communications, Inc. All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+#pragma warning disable
+
+using System.Diagnostics;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
+using NATS.Client.Core;
+using NATS.Jwt;
+using NATS.Jwt.Models;
+using NATS.Net;
+using NATS.NKeys;
+using Synadia.AuthCallout;
+
+namespace Compat;
+
+public class Testing
+{
+ private const string SubjectServiceAll = ".test.service.>";
+ private const string SubjectServiceStop = ".test.service.stop";
+ private const string SubjectServiceSync = ".test.service.sync";
+ private const string SubjectDriverConnected = ".test.driver.connected";
+ private const string SubjectDriverError = ".test.driver.error";
+ private const string SubjectDriverSync = ".test.driver.sync";
+ private const string SubjectDriverVars = ".test.driver.vars";
+
+ private const string LogName = "SERV";
+ private const string EnvCompatDebug = "X_COMPAT_DEBUG";
+ private const string CompatRoot = ".compat.root";
+ private readonly string[] _args;
+ private readonly string _cwd;
+ private readonly string _exe;
+ private readonly int _debug;
+
+ public Testing(string[] args)
+ {
+ _args = args;
+ _exe = Path.GetFullPath(Process.GetCurrentProcess().MainModule!.FileName);
+ _cwd = SetCurrentDirectoryToProjectRoot();
+ _debug = GetDebugFlagValue();
+ Log(1, $"Starting...");
+ Log(3, $"Exe path {_exe}");
+ }
+
+ private static string SetCurrentDirectoryToProjectRoot()
+ {
+ var cwd = new DirectoryInfo(Directory.GetCurrentDirectory());
+
+ while (cwd!.GetFiles().All(f => f.Name != CompatRoot))
+ {
+ cwd = cwd.Parent;
+ }
+
+ Directory.SetCurrentDirectory(cwd.FullName);
+
+ return cwd.FullName;
+ }
+
+ bool WillLog(int level) => level <= _debug;
+
+ void Log(int level, string message)
+ {
+ if (WillLog(level))
+ {
+ Console.WriteLine($"[{LogName}] [{level}] {message}");
+ }
+ }
+
+ void Err(string message)
+ {
+ Console.Error.WriteLine($"[{LogName}] ERROR {message}");
+ }
+
+ private int GetDebugFlagValue()
+ {
+ string? debugString = Environment.GetEnvironmentVariable(EnvCompatDebug);
+ if (debugString == null)
+ {
+ return 0;
+ }
+
+ debugString = debugString.Trim().ToLowerInvariant();
+ if (Regex.IsMatch(debugString, @"^(-|\+)?\s*\d+$"))
+ {
+ return int.Parse(debugString);
+ }
+
+ return Regex.IsMatch(debugString, @"^(false|no|off)$") ? 0 : 1;
+ }
+
+ public int Run()
+ {
+ Log(3, $"Args: {string.Join(" ", _args)}");
+
+ if (_args.Length == 0)
+ {
+ var go = new Go(_cwd, _exe);
+ int e = go.Test();
+ return e;
+ }
+ else if (_args.Length > 0 && _args[0] == "-r")
+ {
+ Log(1, "Running tests...");
+ if (_args.Length > 2)
+ {
+ string suitName = _args[1];
+ string natsCoordinationUrl = _args[2];
+ try
+ {
+ Log(3, $"Starting auth service for '{suitName}' on '{natsCoordinationUrl}'");
+ StartAuthServiceAndWaitForTests(suitName, natsCoordinationUrl);
+
+ Log(1, "Tests completed");
+ return 0;
+ }
+ catch (Exception e1)
+ {
+ Err($"Error starting auth service: {e1}");
+ return 1;
+ }
+ }
+ else
+ {
+ Err("No NATS tests coordination URL provided");
+ return 1;
+ }
+ }
+ else
+ {
+ Err("""
+ Usage:
+ Run tests: (starts Go test)
+ compat
+ Run tests with auth service: (called from Go test)
+ compat -r
+ """);
+ return 1;
+ }
+ }
+
+ private static async Task InitializeAndStartAuthServiceAndWait(TestContext t, NatsAuthServiceOpts opts)
+ {
+ await using var service = new NatsAuthService(t.connection.CreateServicesContext(), opts);
+ await service.StartAsync(t.cts.Token);
+ await t.nt.RequestAsync(t.Subject(SubjectDriverConnected), cancellationToken: t.cts.Token);
+ await t.tcs.Task;
+ }
+
+ private Func CreateAuthServiceErrorHandler(TestContext t) => async (e, ct) =>
+ {
+ Log(1, $"Auth error: {e}");
+ await t.nt.PublishAsync(t.Subject(SubjectDriverError), e.Message, cancellationToken: ct);
+ };
+
+ private void StartAuthServiceAndWaitForTests(string suitName, string natsCoordinationUrl)
+ {
+ string[] parts = suitName.Split('/');
+ string env = parts[0];
+ string name = parts[1];
+ string Subject(string subject) => suitName + subject;
+
+ Log(2, $"Connecting to '{natsCoordinationUrl}' for env:{env} name:{name} ...");
+
+ Task.Run(async () =>
+ {
+ await using var nt = new NatsConnection(new NatsOpts { Url = natsCoordinationUrl });
+ var rttTest = await nt.PingAsync();
+ Log(1, $"Ping to test coordination server {natsCoordinationUrl}: {rttTest}");
+ var cts = new CancellationTokenSource();
+ var tcs = new TaskCompletionSource();
+ var serviceSub = Task.Run(async () =>
+ {
+ await foreach (NatsMsg m in nt.SubscribeAsync(Subject(SubjectServiceAll),
+ cancellationToken: cts.Token))
+ {
+ if (m.Subject == Subject(SubjectServiceSync))
+ {
+ await m.ReplyAsync("Ok", cancellationToken: cts.Token);
+ }
+ else if (m.Subject == Subject(SubjectServiceStop))
+ {
+ Log(2, "Stopping test service");
+ await cts.CancelAsync();
+ tcs.TrySetResult();
+ break;
+ }
+ }
+ }, cts.Token);
+
+ var jsonMsg = await nt.RequestAsync(Subject(SubjectDriverVars), cancellationToken: cts.Token);
+
+ if (WillLog(4))
+ {
+ Log(4, JsonNode.Parse(jsonMsg.Data).ToString());
+ }
+
+ CompatVars cv = CompatVars.FromJson(suitName, jsonMsg.Data);
+
+ NatsAuthOpts authOpts;
+ if (string.IsNullOrWhiteSpace(cv.ServiceCreds))
+ {
+ authOpts = new NatsAuthOpts { Username = cv.Username, Password = cv.Password };
+ }
+ else
+ {
+ authOpts = new NatsAuthOpts { CredsFile = cv.ServiceCreds };
+ }
+
+ await using var connection = new NatsConnection(new()
+ {
+ Url = cv.Url,
+ AuthOpts = authOpts,
+ });
+ var rtt = await connection.PingAsync(cts.Token);
+ Log(3, $"Connection RTT {rtt}");
+
+ Log(3, $"{cv}");
+
+ var t = new TestContext
+ {
+ suitName = suitName,
+ name = name,
+ env = env,
+ cv = cv,
+ cts = cts,
+ tcs = tcs,
+ nt = nt,
+ connection = connection,
+ jwt = new NatsJwt(),
+ };
+
+ MethodInfo? methodInfo = GetType().GetMethod(name);
+ if (methodInfo == null)
+ {
+ throw new Exception($"No test method found for '{name}'");
+ }
+
+ Log(2, $"Calling test method '{name}'");
+ await (Task)methodInfo.Invoke(this, [t]);
+
+ await serviceSub;
+ }).Wait();
+
+ Log(2, "Service stopped");
+ }
+
+ public async Task TestEncryptionMismatch(TestContext t)
+ {
+ ValueTask Authorizer(NatsAuthorizationRequest r, CancellationToken cancellationToken)
+ {
+ throw new Exception("checks at the handler should stop the request before it gets here");
+ }
+
+ ValueTask ResponseSigner(NatsAuthorizationResponseClaims r, CancellationToken cancellationToken)
+ {
+ throw new Exception("checks at the handler should stop the request before it gets here");
+ }
+
+ NatsAuthServiceOpts opts = new(Authorizer, ResponseSigner)
+ {
+ ErrorHandler = CreateAuthServiceErrorHandler(t),
+
+ // do the opposite of the server setup so that when server is sending encrypted
+ // data, the client is not able to decrypt it and vice versa.
+ EncryptionKey = t.cv.Ekp == null ? KeyPair.CreatePair(PrefixByte.Curve) : null,
+ };
+
+ await InitializeAndStartAuthServiceAndWait(t, opts);
+ }
+
+ public async Task TestSetupOK(TestContext t)
+ {
+ async ValueTask Authorizer(NatsAuthorizationRequest r, CancellationToken cancellationToken)
+ {
+ Log(2, $"Auth user: {r.NatsConnectOptions.Username}");
+ NatsUserClaims user = t.jwt.NewUserClaims(r.UserNKey);
+ user.Audience = t.cv.Audience;
+ user.User.Pub.Allow = [t.cv.UserInfoSubj];
+ user.User.Sub.Allow = ["_INBOX.>"];
+ user.Expires = DateTimeOffset.Now + TimeSpan.FromSeconds(90);
+
+ return t.Encoder.Encode(user);
+ }
+
+ async ValueTask ResponseSigner(NatsAuthorizationResponseClaims r, CancellationToken cancellationToken)
+ {
+ return t.Encoder.Encode(r);
+ }
+
+ NatsAuthServiceOpts opts = new(Authorizer, ResponseSigner)
+ {
+ ErrorHandler = CreateAuthServiceErrorHandler(t),
+ EncryptionKey = t.cv.Ekp,
+ };
+
+ await InitializeAndStartAuthServiceAndWait(t, opts);
+ }
+
+ public async Task TestAbortRequest(TestContext t)
+ {
+ async ValueTask Authorizer(NatsAuthorizationRequest r, CancellationToken cancellationToken)
+ {
+ Log(2, $"Auth user: {r.NatsConnectOptions.Username}");
+
+ if (r.NatsConnectOptions.Username == "blacklisted")
+ {
+ throw new Exception("abort request");
+ }
+
+ if (r.NatsConnectOptions.Username == "errorme")
+ {
+ throw new Exception("service error: testing errorme");
+ }
+
+ if (r.NatsConnectOptions.Username == "blank")
+ {
+ return "";
+ }
+
+ NatsUserClaims user = t.jwt.NewUserClaims(r.UserNKey);
+ user.Audience = t.cv.Audience;
+ user.User.Pub.Allow = [t.cv.UserInfoSubj];
+ user.User.Sub.Allow = ["_INBOX.>"];
+ user.Expires = DateTimeOffset.Now + TimeSpan.FromSeconds(90);
+
+ return t.Encoder.Encode(user);
+ }
+
+ async ValueTask ResponseSigner(NatsAuthorizationResponseClaims r, CancellationToken cancellationToken)
+ {
+ return t.Encoder.Encode(r);
+ }
+
+ NatsAuthServiceOpts opts = new(Authorizer, ResponseSigner)
+ {
+ ErrorHandler = CreateAuthServiceErrorHandler(t),
+ EncryptionKey = t.cv.Ekp,
+ };
+
+ await InitializeAndStartAuthServiceAndWait(t, opts);
+ }
+}
diff --git a/tests/compat/authservice_test.go b/tests/compat/authservice_test.go
new file mode 100644
index 0000000..31ad3f2
--- /dev/null
+++ b/tests/compat/authservice_test.go
@@ -0,0 +1,154 @@
+// Copyright (c) Synadia Communications, Inc. All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+// Adapted from https://github.com/synadia-io/callout.go
+
+package compat
+
+import (
+ "github.com/aricart/nst.go"
+ "github.com/nats-io/jwt/v2"
+ "github.com/nats-io/nats.go"
+ "github.com/nats-io/nkeys"
+ "github.com/stretchr/testify/suite"
+ "os"
+ "testing"
+)
+
+type CalloutSuite struct {
+ suite.Suite
+ dir *nst.TestDir
+ env CalloutEnv
+ ns nst.NatsServer
+ // setup second server to talk to auth service process
+ dir2 *nst.TestDir
+ ns2 nst.NatsServer
+}
+
+type CalloutEnv interface {
+ GetServerConf() []byte
+ ServiceUserOpts() []nats.Option
+ ServiceAudience() string
+ EncryptionKey() nkeys.KeyPair
+ Audience() string
+ EncodeUser(account string, claim jwt.Claims) (string, error)
+ UserOpts() []nats.Option
+ GetAccounts() map[string]nkeys.KeyPair
+ ServiceCreds() string
+}
+
+func NewCalloutSuite(t *testing.T) *CalloutSuite {
+ return &CalloutSuite{
+ dir: nst.NewTestDir(t, os.TempDir(), "callout_test"),
+ dir2: nst.NewTestDir(t, os.TempDir(), "callout2_test"),
+ }
+}
+
+func (s *CalloutSuite) SetupServer(conf []byte) nst.NatsServer {
+ return nst.NewNatsServer(s.dir, &nst.Options{
+ ConfigFile: s.dir.WriteFile("server.conf", conf),
+ Port: -1,
+ })
+}
+
+func (s *CalloutSuite) SetupSuite() {
+ s.ns = s.SetupServer(s.env.GetServerConf())
+ s.ns2 = nst.NewNatsServer(s.dir2, nil)
+}
+
+func (s *CalloutSuite) TearDownSuite() {
+ s.ns.Shutdown()
+ s.dir.Cleanup()
+}
+
+func (s *CalloutSuite) getServiceConn() *nats.Conn {
+ nc, err := s.ns.MaybeConnect(s.env.ServiceUserOpts()...)
+ s.NoError(err)
+ return nc
+}
+
+func (s *CalloutSuite) userConn(opts ...nats.Option) (*nats.Conn, error) {
+ buf := append(opts, s.env.UserOpts()...)
+ return s.ns.MaybeConnect(buf...)
+}
+
+func TestBasicEnv(t *testing.T) {
+ cs := NewCalloutSuite(t)
+ cs.env = NewBasicEnv(t, cs.dir)
+ suite.Run(t, cs)
+}
+
+func TestBasicAccountEnv(t *testing.T) {
+ cs := NewCalloutSuite(t)
+ cs.env = NewBasicAccountEnv(t, cs.dir)
+ suite.Run(t, cs)
+}
+
+func TestBasicEncryptedEnv(t *testing.T) {
+ cs := NewCalloutSuite(t)
+ cs.env = NewBasicEncryptedEnv(t, cs.dir)
+ suite.Run(t, cs)
+}
+
+func TestDelegatedEnv(t *testing.T) {
+ cs := NewCalloutSuite(t)
+ cs.env = NewDelegatedEnv(t, cs.dir)
+ suite.Run(t, cs)
+}
+
+func TestDelegatedKeysEnv(t *testing.T) {
+ cs := NewCalloutSuite(t)
+ cs.env = NewDelegatedKeysEnv(t, cs.dir)
+ suite.Run(t, cs)
+}
+
+func (s *CalloutSuite) TestEncryptionMismatch() {
+ es := StartExternalAuthService(s)
+ defer es.Stop()
+
+ // this should timeout, but shown as Authorization Violation because the service is NOT running due to the mismatch
+ _, err := s.userConn(nats.MaxReconnects(1))
+ s.Error(err)
+ lastErr := es.GetLastError()
+ s.Contains(lastErr, "encryption mismatch")
+}
+
+func (s *CalloutSuite) TestSetupOK() {
+ es := StartExternalAuthService(s)
+ defer es.Stop()
+
+ c, err := s.userConn(nats.UserInfo("hello", "world"))
+ s.NoError(err)
+ s.NotNil(c)
+ info := nst.ClientInfo(s.T(), c)
+ s.Contains(info.Data.Permissions.Pub.Allow, nst.UserInfoSubj)
+ s.Contains(info.Data.Permissions.Sub.Allow, "_INBOX.>")
+}
+
+func (s *CalloutSuite) TestAbortRequest() {
+ es := StartExternalAuthService(s)
+ defer es.Stop()
+
+ nc, err := s.userConn(nats.UserInfo("hello", "world"))
+ s.NoError(err)
+ s.NotNil(nc)
+ defer nc.Close()
+
+ _, err = s.userConn(
+ nats.UserInfo("errorme", ""),
+ nats.MaxReconnects(1),
+ )
+ s.Error(err)
+
+ _, err = s.userConn(
+ nats.UserInfo("blacklisted", ""),
+ nats.MaxReconnects(1),
+ )
+ s.Error(err)
+
+ _, err = s.userConn(
+ nats.UserInfo("blank", ""),
+ nats.MaxReconnects(1),
+ )
+ s.Error(err)
+}
diff --git a/tests/compat/compat.csproj b/tests/compat/compat.csproj
new file mode 100644
index 0000000..f2ceef6
--- /dev/null
+++ b/tests/compat/compat.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ true
+ true
+ false
+
+
+
+
+
+
+
diff --git a/tests/compat/env_accountconf_test.go b/tests/compat/env_accountconf_test.go
new file mode 100644
index 0000000..1ce7e6e
--- /dev/null
+++ b/tests/compat/env_accountconf_test.go
@@ -0,0 +1,95 @@
+// Copyright 2025 Synadia Communications, Inc
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package compat
+
+import (
+ "testing"
+
+ "github.com/aricart/nst.go"
+ "github.com/nats-io/jwt/v2"
+ "github.com/nats-io/nats.go"
+ "github.com/nats-io/nkeys"
+ "github.com/stretchr/testify/require"
+)
+
+type BasicAccountEnv struct {
+ t *testing.T
+ dir *nst.TestDir
+ akp nkeys.KeyPair
+}
+
+func NewBasicAccountEnv(t *testing.T, dir *nst.TestDir) *BasicAccountEnv {
+ akp, err := nkeys.CreateAccount()
+ require.NoError(t, err)
+ return &BasicAccountEnv{
+ t: t,
+ dir: dir,
+ akp: akp,
+ }
+}
+
+func (bc *BasicAccountEnv) GetServerConf() []byte {
+ pk, err := bc.akp.PublicKey()
+ require.NoError(bc.t, err)
+
+ conf := &nst.Conf{Accounts: map[string]nst.Account{}}
+ // the auth user is running in its own account
+ conf.Accounts["AUTH"] = nst.Account{
+ Users: []nst.User{
+ {User: "auth", Password: "pwd"},
+ },
+ }
+ conf.Authorization.AuthCallout = &nst.AuthCallout{}
+ conf.Authorization.AuthCallout.Issuer = pk
+ conf.Authorization.AuthCallout.Account = "AUTH"
+ conf.Authorization.AuthCallout.AuthUsers.Add("auth")
+
+ // the account to place users in
+ conf.Accounts["B"] = nst.Account{}
+ return conf.Marshal(bc.t)
+}
+
+func (bc *BasicAccountEnv) EncodeUser(_ string, claim jwt.Claims) (string, error) {
+ return claim.Encode(bc.akp)
+}
+
+func (bc *BasicAccountEnv) ServiceUserOpts() []nats.Option {
+ return []nats.Option{nats.UserInfo("auth", "pwd")}
+}
+
+func (bc *BasicAccountEnv) UserOpts() []nats.Option {
+ return []nats.Option{}
+}
+
+func (bc *BasicAccountEnv) EncryptionKey() nkeys.KeyPair {
+ return nil
+}
+
+func (bc *BasicAccountEnv) Audience() string {
+ return "B"
+}
+
+func (bc *BasicAccountEnv) ServiceAudience() string {
+ return "AUTH"
+}
+
+func (bc *BasicAccountEnv) GetAccounts() map[string]nkeys.KeyPair {
+ return map[string]nkeys.KeyPair{
+ "A": bc.akp,
+ }
+}
+
+func (bc *BasicAccountEnv) ServiceCreds() string {
+ return ""
+}
diff --git a/tests/compat/env_basic_encrypted_test.go b/tests/compat/env_basic_encrypted_test.go
new file mode 100644
index 0000000..56d6e22
--- /dev/null
+++ b/tests/compat/env_basic_encrypted_test.go
@@ -0,0 +1,94 @@
+// Copyright 2025 Synadia Communications, Inc
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package compat
+
+import (
+ "testing"
+
+ "github.com/aricart/nst.go"
+ "github.com/nats-io/jwt/v2"
+ "github.com/nats-io/nats.go"
+ "github.com/nats-io/nkeys"
+ "github.com/stretchr/testify/require"
+)
+
+type BasicEncryptedEnv struct {
+ t *testing.T
+ dir *nst.TestDir
+ akp nkeys.KeyPair
+ xkey nkeys.KeyPair
+}
+
+func NewBasicEncryptedEnv(t *testing.T, dir *nst.TestDir) *BasicEncryptedEnv {
+ akp, err := nkeys.CreateAccount()
+ require.NoError(t, err)
+ xkey, err := nkeys.CreateCurveKeys()
+ require.NoError(t, err)
+ return &BasicEncryptedEnv{
+ t: t,
+ dir: dir,
+ akp: akp,
+ xkey: xkey,
+ }
+}
+
+func (bc *BasicEncryptedEnv) GetServerConf() []byte {
+ pk, err := bc.akp.PublicKey()
+ require.NoError(bc.t, err)
+
+ pck, err := bc.xkey.PublicKey()
+ require.NoError(bc.t, err)
+
+ conf := &nst.Conf{Accounts: map[string]nst.Account{}}
+ conf.Authorization.Users.Add(nst.User{User: "auth", Password: "pwd"})
+ conf.Authorization.AuthCallout = &nst.AuthCallout{}
+ conf.Authorization.AuthCallout.Issuer = pk
+ conf.Authorization.AuthCallout.XKey = pck
+ conf.Authorization.AuthCallout.AuthUsers.Add("auth")
+ return conf.Marshal(bc.t)
+}
+
+func (bc *BasicEncryptedEnv) EncodeUser(_ string, claim jwt.Claims) (string, error) {
+ return claim.Encode(bc.akp)
+}
+
+func (bc *BasicEncryptedEnv) ServiceUserOpts() []nats.Option {
+ return []nats.Option{nats.UserInfo("auth", "pwd")}
+}
+
+func (bc *BasicEncryptedEnv) UserOpts() []nats.Option {
+ return []nats.Option{}
+}
+
+func (bc *BasicEncryptedEnv) EncryptionKey() nkeys.KeyPair {
+ return bc.xkey
+}
+
+func (bc *BasicEncryptedEnv) Audience() string {
+ return "$G"
+}
+
+func (bc *BasicEncryptedEnv) ServiceAudience() string {
+ return "$G"
+}
+
+func (bc *BasicEncryptedEnv) GetAccounts() map[string]nkeys.KeyPair {
+ return map[string]nkeys.KeyPair{
+ "A": bc.akp,
+ }
+}
+
+func (bc *BasicEncryptedEnv) ServiceCreds() string {
+ return ""
+}
diff --git a/tests/compat/env_basic_test.go b/tests/compat/env_basic_test.go
new file mode 100644
index 0000000..261749d
--- /dev/null
+++ b/tests/compat/env_basic_test.go
@@ -0,0 +1,76 @@
+// Copyright (c) Synadia Communications, Inc. All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+package compat
+
+import (
+ "testing"
+
+ "github.com/aricart/nst.go"
+ "github.com/nats-io/jwt/v2"
+ "github.com/nats-io/nats.go"
+ "github.com/nats-io/nkeys"
+ "github.com/stretchr/testify/require"
+)
+
+type BasicEnv struct {
+ t testing.TB
+ dir *nst.TestDir
+ akp nkeys.KeyPair
+}
+
+func NewBasicEnv(t testing.TB, dir *nst.TestDir) *BasicEnv {
+ akp, err := nkeys.CreateAccount()
+ require.NoError(t, err)
+ return &BasicEnv{
+ t: t,
+ dir: dir,
+ akp: akp,
+ }
+}
+
+func (bc *BasicEnv) GetServerConf() []byte {
+ pk, err := bc.akp.PublicKey()
+ require.NoError(bc.t, err)
+
+ conf := &nst.Conf{}
+ conf.Authorization.Users.Add(nst.User{User: "auth", Password: "pwd"})
+ conf.Authorization.AuthCallout = &nst.AuthCallout{}
+ conf.Authorization.AuthCallout.Issuer = pk
+ conf.Authorization.AuthCallout.AuthUsers.Add("auth")
+ return conf.Marshal(bc.t)
+}
+
+func (bc *BasicEnv) EncodeUser(_ string, claim jwt.Claims) (string, error) {
+ return claim.Encode(bc.akp)
+}
+
+func (bc *BasicEnv) ServiceUserOpts() []nats.Option {
+ return []nats.Option{nats.UserInfo("auth", "pwd")}
+}
+
+func (bc *BasicEnv) UserOpts() []nats.Option {
+ return []nats.Option{}
+}
+
+func (bc *BasicEnv) EncryptionKey() nkeys.KeyPair {
+ return nil
+}
+
+func (bc *BasicEnv) Audience() string {
+ return "$G"
+}
+
+func (bc *BasicEnv) ServiceAudience() string {
+ return "$G"
+}
+
+func (bc *BasicEnv) GetAccounts() map[string]nkeys.KeyPair {
+ return map[string]nkeys.KeyPair{
+ "A": bc.akp,
+ }
+}
+
+func (bc *BasicEnv) ServiceCreds() string {
+ return ""
+}
diff --git a/tests/compat/env_delegated_keys_test.go b/tests/compat/env_delegated_keys_test.go
new file mode 100644
index 0000000..62e60b2
--- /dev/null
+++ b/tests/compat/env_delegated_keys_test.go
@@ -0,0 +1,200 @@
+// Copyright 2025 Synadia Communications, Inc
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package compat
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ authb "github.com/synadia-io/jwt-auth-builder.go"
+ "github.com/synadia-io/jwt-auth-builder.go/providers/nsc"
+
+ "github.com/aricart/nst.go"
+ "github.com/nats-io/jwt/v2"
+ "github.com/nats-io/nats.go"
+ "github.com/nats-io/nkeys"
+ "github.com/stretchr/testify/require"
+)
+
+// FIXME: need to handle keys manually
+type DelegatedKeysEnv struct {
+ t *testing.T
+ dir *nst.TestDir
+ auth authb.Auth
+
+ userSigner string
+ authorizationSigner string
+
+ sentinelCreds string
+ serviceCreds string
+ keys map[string]*authb.Key
+}
+
+func NewDelegatedKeysEnv(t *testing.T, dir *nst.TestDir) *DelegatedKeysEnv {
+ // this uses the authb.Auth to build the entities
+ // the option for KeysFn and sign enable to intercept the key creation
+ // so that we have them - the test here will use actual keys to sign
+ // the generated JWTs
+ keys := make(map[string]*authb.Key)
+ auth, err := authb.NewAuthWithOptions(
+ nsc.NewNscProvider(
+ fmt.Sprintf("%s/nsc/stores", dir),
+ fmt.Sprintf("%s/nsc/keys", dir),
+ ), &authb.Options{
+ KeysFn: func(p nkeys.PrefixByte) (*authb.Key, error) {
+ kp, err := nkeys.CreatePair(p)
+ if err != nil {
+ return nil, err
+ }
+ key, err := authb.KeyFromNkey(kp, p)
+ if err != nil {
+ return nil, err
+ }
+ keys[key.Public] = key
+ return key, nil
+ },
+ SignFn: func(pub string, data []byte) ([]byte, error) {
+ k, ok := keys[pub]
+ if !ok {
+ return nil, fmt.Errorf("no key for %s", pub)
+ }
+ return k.Pair.Sign(data)
+ },
+ },
+ )
+ require.NoError(t, err)
+ return &DelegatedKeysEnv{
+ t: t,
+ dir: dir,
+ auth: auth,
+ keys: keys,
+ }
+}
+
+func (bc *DelegatedKeysEnv) GetServerConf() []byte {
+ o, err := bc.auth.Operators().Add("O")
+ require.NoError(bc.t, err)
+
+ sys, err := o.Accounts().Add("SYS")
+ require.NoError(bc.t, err)
+ require.NoError(bc.t, o.SetSystemAccount(sys))
+
+ // account where we place the users
+ a, err := o.Accounts().Add("A")
+ require.NoError(bc.t, err)
+ // we are going to sign users with this key
+ bc.userSigner, err = a.ScopedSigningKeys().Add()
+ require.NoError(bc.t, err)
+
+ // this is the auth callout account
+ c, err := o.Accounts().Add("C")
+ require.NoError(bc.t, err)
+ // we are going to sign authorizations with this key
+ bc.authorizationSigner, err = c.ScopedSigningKeys().Add()
+ require.NoError(bc.t, err)
+
+ cu, err := c.Users().Add("auth_user", "")
+ require.NoError(bc.t, err)
+ serviceCreds, err := cu.Creds(time.Hour)
+ require.NoError(bc.t, err)
+ bc.serviceCreds = bc.dir.WriteFile("service.creds", serviceCreds)
+
+ // configure the external authorization
+ require.NoError(bc.t,
+ c.SetExternalAuthorizationUser([]authb.User{cu}, []authb.Account{a}, ""),
+ )
+
+ // sentinel credentials
+ u, err := c.Users().Add("sentinel", "")
+ require.NoError(bc.t, err)
+ require.NoError(bc.t, u.PubPermissions().SetDeny(">"))
+ require.NoError(bc.t, u.SubPermissions().SetDeny(">"))
+ sentinelCreds, err := u.Creds(time.Hour)
+ require.NoError(bc.t, err)
+ bc.sentinelCreds = bc.dir.WriteFile("sentinel.creds", sentinelCreds)
+
+ // Flush nsc data to disk so we can read it
+ // from the compat process
+ if err := bc.auth.Commit(); err != nil {
+ bc.t.Fatalf("nsc commit error: %v", err)
+ }
+
+ resolver := nst.ResolverFromAuth(bc.t, o)
+ return resolver.Marshal(bc.t)
+}
+
+func (bc *DelegatedKeysEnv) GetAccount(name string) authb.Account {
+ o, err := bc.auth.Operators().Get("O")
+ require.NoError(bc.t, err)
+ require.NotNil(bc.t, o)
+ a, err := o.Accounts().Get(name)
+ require.NoError(bc.t, err)
+ require.NotNil(bc.t, a)
+ return a
+}
+
+func (bc *DelegatedKeysEnv) EncodeUser(
+ account string,
+ claim jwt.Claims,
+) (string, error) {
+ a := bc.GetAccount(account)
+ uc, ok := claim.(*jwt.UserClaims)
+ require.True(bc.t, ok)
+ // set the issuer
+ uc.IssuerAccount = a.Subject()
+ // look up the key we are supposed to use in the cache
+ k, ok := bc.keys[bc.userSigner]
+ if !ok {
+ return "", fmt.Errorf("no key for %s", bc.userSigner)
+ }
+ // sign the user JWT
+ return uc.Encode(k.Pair)
+}
+
+func (bc *DelegatedKeysEnv) ServiceUserOpts() []nats.Option {
+ return []nats.Option{
+ nats.UserCredentials(bc.serviceCreds),
+ }
+}
+
+func (bc *DelegatedKeysEnv) UserOpts() []nats.Option {
+ return []nats.Option{
+ nats.UserCredentials(bc.sentinelCreds),
+ }
+}
+
+func (bc *DelegatedKeysEnv) EncryptionKey() nkeys.KeyPair {
+ return nil
+}
+
+func (bc *DelegatedKeysEnv) Audience() string {
+ a := bc.GetAccount("A")
+ return a.Subject()
+}
+
+func (bc *DelegatedKeysEnv) ServiceAudience() string {
+ c := bc.GetAccount("C")
+ return c.Subject()
+}
+
+func (bc *DelegatedKeysEnv) GetAccounts() map[string]nkeys.KeyPair {
+ return map[string]nkeys.KeyPair{
+ "A": bc.keys[bc.userSigner].Pair,
+ }
+}
+
+func (bc *DelegatedKeysEnv) ServiceCreds() string {
+ return bc.serviceCreds
+}
diff --git a/tests/compat/env_delegated_test.go b/tests/compat/env_delegated_test.go
new file mode 100644
index 0000000..0c3f61d
--- /dev/null
+++ b/tests/compat/env_delegated_test.go
@@ -0,0 +1,168 @@
+// Copyright 2025 Synadia Communications, Inc
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package compat
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ authb "github.com/synadia-io/jwt-auth-builder.go"
+ "github.com/synadia-io/jwt-auth-builder.go/providers/nsc"
+
+ "github.com/aricart/nst.go"
+ "github.com/nats-io/jwt/v2"
+ "github.com/nats-io/nats.go"
+ "github.com/nats-io/nkeys"
+ "github.com/stretchr/testify/require"
+)
+
+type DelegatedEnv struct {
+ t *testing.T
+ dir *nst.TestDir
+ auth authb.Auth
+ aSigningKey string
+
+ sentinelCreds string
+ serviceCreds string
+ cSigningKey string
+}
+
+func NewDelegatedEnv(t *testing.T, dir *nst.TestDir) *DelegatedEnv {
+ // NscProvider shouldn't be used in production (should use the KvProvider) or
+ // simply use Keys.
+ auth, err := authb.NewAuth(
+ nsc.NewNscProvider(
+ fmt.Sprintf("%s/nsc/stores", dir),
+ fmt.Sprintf("%s/nsc/keys", dir),
+ ),
+ )
+ require.NoError(t, err)
+ return &DelegatedEnv{
+ t: t,
+ dir: dir,
+ auth: auth,
+ }
+}
+
+func (bc *DelegatedEnv) GetServerConf() []byte {
+ o, err := bc.auth.Operators().Add("O")
+ require.NoError(bc.t, err)
+
+ sys, err := o.Accounts().Add("SYS")
+ require.NoError(bc.t, err)
+ require.NoError(bc.t, o.SetSystemAccount(sys))
+
+ // account where we place the users
+ a, err := o.Accounts().Add("A")
+ require.NoError(bc.t, err)
+ bc.aSigningKey, err = a.ScopedSigningKeys().Add()
+
+ // this is the auth callout account
+ c, err := o.Accounts().Add("C")
+ require.NoError(bc.t, err)
+ bc.cSigningKey, err = c.ScopedSigningKeys().Add()
+ require.NoError(bc.t, err)
+
+ cu, err := c.Users().Add("auth_user", "")
+ require.NoError(bc.t, err)
+ serviceCreds, err := cu.Creds(time.Hour)
+ require.NoError(bc.t, err)
+ bc.serviceCreds = bc.dir.WriteFile("service.creds", serviceCreds)
+
+ // configure the external authorization
+ require.NoError(bc.t,
+ c.SetExternalAuthorizationUser([]authb.User{cu}, []authb.Account{a}, ""),
+ )
+
+ // sentinel credentials
+ u, err := c.Users().Add("sentinel", "")
+ require.NoError(bc.t, err)
+ require.NoError(bc.t, u.PubPermissions().SetDeny(">"))
+ require.NoError(bc.t, u.SubPermissions().SetDeny(">"))
+ sentinelCreds, err := u.Creds(time.Hour)
+ require.NoError(bc.t, err)
+ bc.sentinelCreds = bc.dir.WriteFile("sentinel.creds", sentinelCreds)
+
+ // Flush nsc data to disk so we can read it
+ // from the compat process
+ if err := bc.auth.Commit(); err != nil {
+ bc.t.Fatalf("nsc commit error: %v", err)
+ }
+
+ resolver := nst.ResolverFromAuth(bc.t, o)
+ return resolver.Marshal(bc.t)
+}
+
+func (bc *DelegatedEnv) GetAccount(name string) authb.Account {
+ o, err := bc.auth.Operators().Get("O")
+ require.NoError(bc.t, err)
+ require.NotNil(bc.t, o)
+ a, err := o.Accounts().Get(name)
+ require.NoError(bc.t, err)
+ require.NotNil(bc.t, a)
+ return a
+}
+
+func (bc *DelegatedEnv) EncodeUser(
+ account string,
+ claim jwt.Claims,
+) (string, error) {
+ a := bc.GetAccount(account)
+ uc, ok := claim.(*jwt.UserClaims)
+ require.True(bc.t, ok)
+ u, err := a.Users().ImportEphemeral(uc, bc.aSigningKey)
+ require.NoError(bc.t, err)
+ return u.JWT(), nil
+}
+
+func (bc *DelegatedEnv) ServiceUserOpts() []nats.Option {
+ return []nats.Option{
+ nats.UserCredentials(bc.serviceCreds),
+ }
+}
+
+func (bc *DelegatedEnv) UserOpts() []nats.Option {
+ return []nats.Option{
+ nats.UserCredentials(bc.sentinelCreds),
+ }
+}
+
+func (bc *DelegatedEnv) EncryptionKey() nkeys.KeyPair {
+ return nil
+}
+
+func (bc *DelegatedEnv) Audience() string {
+ a := bc.GetAccount("A")
+ return a.Subject()
+}
+
+func (bc *DelegatedEnv) ServiceAudience() string {
+ c := bc.GetAccount("C")
+ return c.Subject()
+}
+
+func (bc *DelegatedEnv) GetAccounts() map[string]nkeys.KeyPair {
+ return map[string]nkeys.KeyPair{
+ //"A": bc.keys[bc.userSigner].Pair,
+ }
+}
+
+func (bc *DelegatedEnv) ServiceCreds() string {
+ return bc.serviceCreds
+}
+
+func (bc *DelegatedEnv) SentinelCreds() string {
+ return bc.sentinelCreds
+}
diff --git a/tests/compat/env_x_compat_test.go b/tests/compat/env_x_compat_test.go
new file mode 100644
index 0000000..9d3fe7f
--- /dev/null
+++ b/tests/compat/env_x_compat_test.go
@@ -0,0 +1,298 @@
+package compat
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "github.com/aricart/nst.go"
+ "github.com/nats-io/nats.go"
+ "github.com/nats-io/nkeys"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+const subjectDriverAll = ".test.driver.>"
+const subjectServiceStop = ".test.service.stop"
+const subjectServiceSync = ".test.service.sync"
+const subjectDriverConnected = ".test.driver.connected"
+const subjectDriverSync = ".test.driver.sync"
+const subjectDriverError = ".test.driver.error"
+const subjectDriverVars = ".test.driver.vars"
+
+type ExtService struct {
+ name string
+ t *testing.T
+ s *CalloutSuite
+ cv *CompatVars
+ cmd *exec.Cmd
+ nc2 *nats.Conn
+ mu sync.Mutex
+ errors []string
+}
+
+type CompatKey struct {
+ Seed string `json:"seed"`
+ Pk string `json:"pk"`
+}
+
+type CompatVars struct {
+ SuitName string `json:"suit_name"`
+ NatsOpts NATSOptions `json:"nats_opts"`
+ Audience string `json:"audience"`
+ ServiceAudience string `json:"service_audience"`
+ UserInfoSubj string `json:"user_info_subj"`
+ EncryptionKey CompatKey `json:"encryption_key"`
+ NatsTestUrls []string `json:"nats_test_urls"`
+ AccountKeys map[string]CompatKey `json:"account_keys"`
+ Dir string `json:"dir"`
+ NscDir string `json:"nsc_dir"`
+ ServiceCreds string `json:"service_creds"`
+}
+
+type NATSOptions struct {
+ Urls []string `json:"urls"`
+ User string `json:"user"`
+ Password string `json:"password"`
+}
+
+func StartExternalAuthService(s *CalloutSuite) *ExtService {
+ suitName := s.T().Name()
+ logMessage(1, "Starting external auth service for "+suitName+" ...")
+ cv := createVars(suitName, s)
+
+ nc2, err := s.ns2.MaybeConnect()
+ if err != nil {
+ s.T().Fatalf("can't connect to nats2: %v", err)
+ }
+
+ es := &ExtService{
+ name: suitName,
+ t: s.T(),
+ s: s,
+ cv: cv,
+ nc2: nc2,
+ errors: []string{},
+ }
+
+ waitConnected := make(chan struct{})
+ timeout := time.NewTimer(5 * time.Second) // Set timeout duration as appropriate
+ defer timeout.Stop()
+
+ subscribe, err := nc2.Subscribe(suitName+subjectDriverAll, func(m *nats.Msg) {
+ logMessage(2, "received message: "+string(m.Data))
+
+ if m.Subject == suitName+subjectDriverSync {
+ err := m.Respond([]byte("ok"))
+ if err != nil {
+ logMessage(0, fmt.Sprintf("error in sync response: %v", err))
+ }
+ } else if m.Subject == suitName+subjectDriverConnected {
+ logMessage(1, "Connected")
+ close(waitConnected) // Signal completion
+ err := m.Respond([]byte("ok"))
+ if err != nil {
+ logMessage(0, fmt.Sprintf("error in connected response: %v", err))
+ }
+ } else if m.Subject == suitName+subjectDriverVars {
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(cv); err != nil {
+ s.T().Fatalf("failed to encode CompatVars to JSON: %v", err)
+ }
+ err := m.Respond(buf.Bytes())
+ if err != nil {
+ logMessage(0, fmt.Sprintf("error in vars response: %v", err))
+ }
+ } else if m.Subject == suitName+subjectDriverError {
+ errorMessage := string(m.Data)
+ logMessage(1, fmt.Sprintf("error from service: %s", errorMessage))
+
+ es.mu.Lock()
+ defer es.mu.Unlock()
+ es.errors = append(es.errors, errorMessage)
+ } else {
+ logMessage(0, fmt.Sprintf("unknown subject: %s", m.Subject))
+ }
+ })
+ if err != nil {
+ s.T().Fatalf("error in test coordination sub: %v", err)
+ }
+ logMessage(1, "subscribed to test: "+subscribe.Subject)
+
+ compatExe := os.Getenv("X_COMPAT_EXE")
+ if compatExe == "" {
+ s.T().Fatal("environment variable X_COMPAT_EXE is not set")
+ }
+ logMessage(3, "X_COMPAT_EXE: "+compatExe)
+
+ natsTestUrl := s.ns2.NatsURLs()[0]
+ logMessage(2, "natsTestUrl: "+natsTestUrl)
+
+ cmd := exec.Command(compatExe, "-r", suitName, natsTestUrl) // Replace with your process
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Start(); err != nil {
+ s.T().Fatalf("failed to start process: %v", err)
+ }
+
+ es.cmd = cmd
+
+ logMessage(1, "process started")
+
+ select {
+ case <-waitConnected:
+ logMessage(1, "service connected successfully.")
+ case <-timeout.C:
+ s.T().Fatalf("process failed to connect: %v", err)
+ }
+
+ return es
+}
+
+func (es *ExtService) GetLastError() string {
+ _, _ = es.nc2.Request(es.name+subjectServiceSync, []byte("sync"), 5*time.Second)
+ es.mu.Lock()
+ defer es.mu.Unlock()
+ if len(es.errors) == 0 {
+ logMessage(3, "Get last error: N/A")
+ return ""
+ }
+ lastError := es.errors[len(es.errors)-1]
+ logMessage(2, fmt.Sprintf("Get last error: '%s'", lastError))
+ return lastError
+}
+
+func (es *ExtService) GetErrors() []string {
+ _, _ = es.nc2.Request(es.name+subjectServiceSync, []byte("sync"), 5*time.Second)
+ es.mu.Lock()
+ defer es.mu.Unlock()
+
+ // Return a copy of the errors slice to ensure immutability
+ errorsCopy := make([]string, len(es.errors))
+ copy(errorsCopy, es.errors)
+ return errorsCopy
+}
+
+func (es *ExtService) Stop() {
+ _, _ = es.nc2.Request(es.name+subjectServiceStop, []byte("stop"), 5*time.Second)
+
+ done := make(chan error, 1)
+ go func() {
+ done <- es.cmd.Wait()
+ }()
+ select {
+ case err := <-done:
+ if err != nil {
+ es.t.Fatalf("process exited with error: %v", err)
+ } else {
+ logMessage(1, fmt.Sprintf("process exited successfully, code: %d", es.cmd.ProcessState.ExitCode()))
+ }
+ case <-time.After(5 * time.Second):
+ println("process timed out, killing process...")
+ if killErr := es.cmd.Process.Kill(); killErr != nil {
+ es.t.Fatalf("failed to kill process: %v", killErr)
+ } else {
+ logMessage(1, "process killed successfully")
+ }
+ }
+}
+
+func createVars(suitName string, s *CalloutSuite) *CompatVars {
+ opts := s.env.ServiceUserOpts()
+ opts1 := &nats.Options{}
+
+ for _, opt := range opts {
+ err := opt(opts1)
+ if err != nil {
+ s.T().Fatalf("can't set opts: %v", err)
+ }
+ }
+
+ opts2 := NATSOptions{
+ Urls: s.ns.NatsURLs(),
+ User: opts1.User,
+ Password: opts1.Password,
+ }
+
+ accountKeys := s.env.GetAccounts()
+ compatKeyMap := make(map[string]CompatKey, len(accountKeys))
+ for key, keyPair := range accountKeys {
+ compatKeyMap[key] = getCompatKey(keyPair)
+ }
+
+ return &CompatVars{
+ SuitName: suitName,
+ NatsTestUrls: s.ns2.NatsURLs(),
+ NatsOpts: opts2,
+ Audience: s.env.Audience(),
+ ServiceAudience: s.env.ServiceAudience(),
+ UserInfoSubj: nst.UserInfoSubj,
+ EncryptionKey: getCompatKey(s.env.EncryptionKey()),
+ AccountKeys: compatKeyMap,
+ Dir: s.dir.Dir,
+ NscDir: filepath.Join(s.dir.Dir, "nsc"),
+ ServiceCreds: s.env.ServiceCreds(),
+ }
+}
+
+func getCompatKey(kp nkeys.KeyPair) CompatKey {
+ return CompatKey{
+ Seed: getSeed(kp),
+ Pk: getPK(kp),
+ }
+}
+
+func getPK(kp nkeys.KeyPair) string {
+ if kp == nil {
+ return ""
+ }
+ pk, err := kp.PublicKey()
+ if err != nil {
+ return ""
+ }
+ return pk
+}
+
+func getSeed(kp nkeys.KeyPair) string {
+ if kp == nil {
+ return ""
+ }
+ seed, err := kp.Seed()
+ if err != nil {
+ return ""
+ }
+ return string(seed)
+}
+
+var globalDebugLevel int
+
+func init() {
+ globalDebugLevel = getDebugLevel()
+}
+
+func getDebugLevel() int {
+ if debugEnv, exists := os.LookupEnv("X_COMPAT_DEBUG"); exists {
+ switch strings.ToLower(debugEnv) {
+ case "no", "off", "false":
+ return 0
+ default:
+ if num, err := strconv.Atoi(debugEnv); err == nil {
+ return num
+ } else {
+ return 1
+ }
+ }
+ }
+ return 0
+}
+
+func logMessage(level int, message string) {
+ if level <= globalDebugLevel {
+ fmt.Printf("[TEST] [%d] %s\n", level, message)
+ }
+}
diff --git a/tests/compat/go.mod b/tests/compat/go.mod
new file mode 100644
index 0000000..94767de
--- /dev/null
+++ b/tests/compat/go.mod
@@ -0,0 +1,29 @@
+module github.com/synadia-io/callout.net/t
+
+go 1.23.0
+
+require (
+ github.com/aricart/nst.go v0.1.1
+ github.com/nats-io/jwt/v2 v2.7.3
+ github.com/nats-io/nats.go v1.39.1
+ github.com/nats-io/nkeys v0.4.10
+ github.com/stretchr/testify v1.10.0
+ github.com/synadia-io/jwt-auth-builder.go v0.0.4
+)
+
+require (
+ dario.cat/mergo v1.0.1 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/google/go-tpm v0.9.3 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
+ github.com/minio/highwayhash v1.0.3 // indirect
+ github.com/mitchellh/go-homedir v1.1.0 // indirect
+ github.com/nats-io/nats-server/v2 v2.11.0-preview.2 // indirect
+ github.com/nats-io/nsc/v2 v2.10.3-0.20250110165315-eeda721ecff6 // indirect
+ github.com/nats-io/nuid v1.0.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ golang.org/x/crypto v0.33.0 // indirect
+ golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/time v0.10.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/tests/compat/go.sum b/tests/compat/go.sum
new file mode 100644
index 0000000..a673fbf
--- /dev/null
+++ b/tests/compat/go.sum
@@ -0,0 +1,52 @@
+dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
+dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/aricart/nst.go v0.1.1 h1:mApOHzicNgeQGh91R81TUhE0n+7vqZmkI0QH+Qv0C+M=
+github.com/aricart/nst.go v0.1.1/go.mod h1:N0yWlAR0nNa+Bkl2onPbOi9+LqXmcwg2WBZKHKanbyk=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
+github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q=
+github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/nats-io/jwt/v2 v2.7.3 h1:6bNPK+FXgBeAqdj4cYQ0F8ViHRbi7woQLq4W29nUAzE=
+github.com/nats-io/jwt/v2 v2.7.3/go.mod h1:GvkcbHhKquj3pkioy5put1wvPxs78UlZ7D/pY+BgZk4=
+github.com/nats-io/nats-server/v2 v2.11.0-preview.2 h1:tT/UeBbFzHRzwy77T/+/Rbw58XP9F3CY3VmtcDltZ68=
+github.com/nats-io/nats-server/v2 v2.11.0-preview.2/go.mod h1:ILDVzrTqMco4rQMOgEZimBjJHb1oZDlz1J+qhJtZlRM=
+github.com/nats-io/nats.go v1.39.1 h1:oTkfKBmz7W047vRxV762M67ZdXeOtUgvbBaNoQ+3PPk=
+github.com/nats-io/nats.go v1.39.1/go.mod h1:MgRb8oOdigA6cYpEPhXJuRVH6UE/V4jblJ2jQ27IXYM=
+github.com/nats-io/nkeys v0.4.10 h1:glmRrpCmYLHByYcePvnTBEAwawwapjCPMjy2huw20wc=
+github.com/nats-io/nkeys v0.4.10/go.mod h1:OjRrnIKnWBFl+s4YK5ChQfvHP2fxqZexrKJoVVyWB3U=
+github.com/nats-io/nsc/v2 v2.10.3-0.20250110165315-eeda721ecff6 h1:V1uh9L3rGIUeYoMd0/EZY2Tvy5+1CkgGpDRx8t3+PNY=
+github.com/nats-io/nsc/v2 v2.10.3-0.20250110165315-eeda721ecff6/go.mod h1:ScomAvx1cgjiXzW3WpGo9x/lLENkwELhewCcok/GTU8=
+github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
+github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/synadia-io/jwt-auth-builder.go v0.0.4 h1:cfTMDAa9iylnD/O6kXqE8Mk51F36kyuQ6BhRrT1svfI=
+github.com/synadia-io/jwt-auth-builder.go v0.0.4/go.mod h1:8WYR7+nLQcDMBpocuPgdFJ5/2UOr+HPll3qv+KNdGvs=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
+golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=