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 ⚠️ - [![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 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=