From 5a27056edbb72d627fbd673bb6d38c63f5ecebc9 Mon Sep 17 00:00:00 2001 From: Caleb Lloyd <2414837+caleblloyd@users.noreply.github.com> Date: Fri, 4 Apr 2025 12:12:32 -0400 Subject: [PATCH 1/7] xunit v3 (#2) Signed-off-by: Caleb Lloyd --- .editorconfig | 10 ++-------- Directory.Build.props | 2 +- .../Synadia.AuthCallout.Tests/AuthServiceTest.cs | 2 -- .../Synadia.AuthCallout.Tests.csproj | 16 +++++++++++----- 4 files changed, 14 insertions(+), 16 deletions(-) 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/Directory.Build.props b/Directory.Build.props index 6a106de..83369dc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,7 +17,7 @@ true Synadia Communications, Inc. Synadia - © Synadia Communications, Inc. All rights reserved. + Synadia Communications, Inc. All rights reserved. https://github.com/synadia-io/callout.net $(PackageProjectUrl) git diff --git a/tests/Synadia.AuthCallout.Tests/AuthServiceTest.cs b/tests/Synadia.AuthCallout.Tests/AuthServiceTest.cs index 80336ef..6a6dd1d 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); diff --git a/tests/Synadia.AuthCallout.Tests/Synadia.AuthCallout.Tests.csproj b/tests/Synadia.AuthCallout.Tests/Synadia.AuthCallout.Tests.csproj index 9966d93..9c2cfb3 100644 --- a/tests/Synadia.AuthCallout.Tests/Synadia.AuthCallout.Tests.csproj +++ b/tests/Synadia.AuthCallout.Tests/Synadia.AuthCallout.Tests.csproj @@ -10,14 +10,20 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + - + From 01603fbfb3b29a5619d07e97ae409be95be8cbed Mon Sep 17 00:00:00 2001 From: Caleb Lloyd <2414837+caleblloyd@users.noreply.github.com> Date: Fri, 4 Apr 2025 12:38:50 -0400 Subject: [PATCH 2/7] fix inadvertent change in Directory.Build.props (#3) Signed-off-by: Caleb Lloyd --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 83369dc..6a106de 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,7 +17,7 @@ true Synadia Communications, Inc. Synadia - Synadia Communications, Inc. All rights reserved. + © Synadia Communications, Inc. All rights reserved. https://github.com/synadia-io/callout.net $(PackageProjectUrl) git From d541bc5ad12ee08dcd0f51e22f2a2670dbfdad08 Mon Sep 17 00:00:00 2001 From: mtmk Date: Mon, 7 Apr 2025 14:49:59 +0100 Subject: [PATCH 3/7] Add Go lib compatibility tests (#4) * Add Go lib compatibility tests * Setup Go in test workflow * Update Go module and fix path separator in workflow Removed the toolchain directive from go.mod as it is redundant. Updated the path separator in GitHub Actions workflow to ensure compatibility across platforms. * Add cancellation and test tidy up * Add tests * Fix format * Tidy up tests * Add initial delegated support bits * Add delegated env support * Add TestAbortRequest * Tidy up encoding * Fix format * Enable AOT publishing in compat project * Tidy up tests * Fix format --- .github/workflows/test.yml | 14 + callout.net.sln | 7 + .../Example.AuthService.csproj | 23 +- examples/Example.AuthService/Program.cs | 6 +- src/Synadia.AuthCallout/INatsAuthService.cs | 6 +- src/Synadia.AuthCallout/NatsAuthService.cs | 100 ++--- .../NatsAuthServiceAuthException.cs | 10 +- .../NatsAuthServiceOpts.cs | 10 +- .../Synadia.AuthCallout.csproj | 22 +- .../AuthServiceTest.cs | 12 +- .../Synadia.AuthCallout.Tests.csproj | 31 +- tests/compat/.compat.root | 1 + tests/compat/ChildProcessTracker.cs | 174 +++++++++ tests/compat/Go.cs | 64 ++++ tests/compat/NscStore.cs | 218 +++++++++++ tests/compat/Program.cs | 47 +++ tests/compat/TestContext.cs | 180 +++++++++ tests/compat/Testing.cs | 347 ++++++++++++++++++ tests/compat/authservice_test.go | 154 ++++++++ tests/compat/compat.csproj | 17 + tests/compat/env_accountconf_test.go | 95 +++++ tests/compat/env_basic_encrypted_test.go | 94 +++++ tests/compat/env_basic_test.go | 76 ++++ tests/compat/env_delegated_keys_test.go | 200 ++++++++++ tests/compat/env_delegated_test.go | 168 +++++++++ tests/compat/env_x_compat_test.go | 298 +++++++++++++++ tests/compat/go.mod | 29 ++ tests/compat/go.sum | 52 +++ 28 files changed, 2345 insertions(+), 110 deletions(-) create mode 100644 tests/compat/.compat.root create mode 100644 tests/compat/ChildProcessTracker.cs create mode 100644 tests/compat/Go.cs create mode 100644 tests/compat/NscStore.cs create mode 100644 tests/compat/Program.cs create mode 100644 tests/compat/TestContext.cs create mode 100644 tests/compat/Testing.cs create mode 100644 tests/compat/authservice_test.go create mode 100644 tests/compat/compat.csproj create mode 100644 tests/compat/env_accountconf_test.go create mode 100644 tests/compat/env_basic_encrypted_test.go create mode 100644 tests/compat/env_basic_test.go create mode 100644 tests/compat/env_delegated_keys_test.go create mode 100644 tests/compat/env_delegated_test.go create mode 100644 tests/compat/env_x_compat_test.go create mode 100644 tests/compat/go.mod create mode 100644 tests/compat/go.sum 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/callout.net.sln b/callout.net.sln index cbcb1a8..90b3c82 100644 --- a/callout.net.sln +++ b/callout.net.sln @@ -34,6 +34,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\test.yml = .github\workflows\test.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 +49,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 +64,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..c7b9c1a 100644 --- a/src/Synadia.AuthCallout/NatsAuthService.cs +++ b/src/Synadia.AuthCallout/NatsAuthService.cs @@ -40,44 +40,40 @@ 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); + } + catch (NatsAuthServiceException e) + { + _logger.LogWarning(e, "Service error"); + await CallErrorHandlerAsync(e, 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); } }, 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 +82,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 +116,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 +142,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 +169,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..416f074 100644 --- a/src/Synadia.AuthCallout/Synadia.AuthCallout.csproj +++ b/src/Synadia.AuthCallout/Synadia.AuthCallout.csproj @@ -1,16 +1,16 @@  - - netstandard2.0;net8.0;net9.0 - enable - enable - latest - true - + + netstandard2.0;net8.0;net9.0 + enable + enable + latest + true + - - - - + + + + diff --git a/tests/Synadia.AuthCallout.Tests/AuthServiceTest.cs b/tests/Synadia.AuthCallout.Tests/AuthServiceTest.cs index 6a6dd1d..7504a86 100644 --- a/tests/Synadia.AuthCallout.Tests/AuthServiceTest.cs +++ b/tests/Synadia.AuthCallout.Tests/AuthServiceTest.cs @@ -88,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"; @@ -103,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; @@ -162,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"; @@ -177,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 9c2cfb3..2fb36fc 100644 --- a/tests/Synadia.AuthCallout.Tests/Synadia.AuthCallout.Tests.csproj +++ b/tests/Synadia.AuthCallout.Tests/Synadia.AuthCallout.Tests.csproj @@ -1,15 +1,14 @@ - - net8.0 - enable - enable + + net8.0 + enable + enable + false + true + - false - true - - - + @@ -20,14 +19,14 @@ 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= From b0e0e26f088de05fd95f51f31ee0d3233a3274fe Mon Sep 17 00:00:00 2001 From: mtmk Date: Mon, 7 Apr 2025 15:15:27 +0100 Subject: [PATCH 4/7] Release check (#5) --- .github/workflows/release.yml | 60 +++++++++++++++++++ README.md | 16 ++++- callout.net.sln | 1 + .../Synadia.AuthCallout.csproj | 2 + src/Synadia.AuthCallout/version.txt | 2 +- 5 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..da90b56 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +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 + echo "Pushing to NuGet..." + # 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/README.md b/README.md index 586bdb8..0fa6332 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,23 @@ # Callout .NET -## ⚠️ EARLY DEVELOPERS PREVIEW ⚠️ - [![License Apache 2](https://img.shields.io/badge/License-Apache2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![NuGet](https://img.shields.io/nuget/v/Synadia.AuthCallout.svg?cacheSeconds=3600)](https://www.nuget.org/packages/Synadia.AuthCallout) [![Build](https://github.com/synadia-io/callout.net/actions/workflows/test.yml/badge.svg?branch=main)](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 90b3c82..28caa2c 100644 --- a/callout.net.sln +++ b/callout.net.sln @@ -32,6 +32,7 @@ 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}" diff --git a/src/Synadia.AuthCallout/Synadia.AuthCallout.csproj b/src/Synadia.AuthCallout/Synadia.AuthCallout.csproj index 416f074..31a3db8 100644 --- a/src/Synadia.AuthCallout/Synadia.AuthCallout.csproj +++ b/src/Synadia.AuthCallout/Synadia.AuthCallout.csproj @@ -5,6 +5,8 @@ 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..1d1679d 100644 --- a/src/Synadia.AuthCallout/version.txt +++ b/src/Synadia.AuthCallout/version.txt @@ -1 +1 @@ -1.0.0-preview.1 +1.0.0-preview.0 From 5d1b6637c98f1f5d6ebcfce987248e78d0c9bbd8 Mon Sep 17 00:00:00 2001 From: mtmk Date: Mon, 7 Apr 2025 15:34:39 +0100 Subject: [PATCH 5/7] Release 1.0.0-preview.1 (#6) First release --- .github/workflows/release.yml | 3 +-- src/Synadia.AuthCallout/version.txt | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da90b56..f5435e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,8 +50,7 @@ jobs: cd dist ls -lh # this should upload snupkgs in the same folder - echo "Pushing to NuGet..." - # dotnet nuget push *.nupkg -s https://api.nuget.org/v3/index.json -k "${{ secrets.NUGET_API_KEY }}" --skip-duplicate + 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 diff --git a/src/Synadia.AuthCallout/version.txt b/src/Synadia.AuthCallout/version.txt index 1d1679d..5fd7619 100644 --- a/src/Synadia.AuthCallout/version.txt +++ b/src/Synadia.AuthCallout/version.txt @@ -1 +1 @@ -1.0.0-preview.0 +1.0.0-preview.1 From e77fe168a0fe419691d721a04b64033009e6ffe3 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Wed, 9 Apr 2025 09:47:39 -0700 Subject: [PATCH 6/7] Add error message replies (#7) Signed-off-by: Matthew DeVenny --- src/Synadia.AuthCallout/NatsAuthService.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Synadia.AuthCallout/NatsAuthService.cs b/src/Synadia.AuthCallout/NatsAuthService.cs index c7b9c1a..029f69c 100644 --- a/src/Synadia.AuthCallout/NatsAuthService.cs +++ b/src/Synadia.AuthCallout/NatsAuthService.cs @@ -55,16 +55,19 @@ await _server.AddEndpointAsync( { _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, "Generic error"); await CallErrorHandlerAsync(e, cancellationToken); + await msg.ReplyErrorAsync(400, e.Message, cancellationToken: cancellationToken); } }, name: "auth-request-handler", From 0a50147a678d660d61d303bb5962a3bc9a4fad8f Mon Sep 17 00:00:00 2001 From: mtmk Date: Wed, 9 Apr 2025 17:49:32 +0100 Subject: [PATCH 7/7] Release 1.0.0-preview.2 (#8) * Add error message replies (#7) --- src/Synadia.AuthCallout/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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