From c9c24a5dbf0d35d7a246fa235c9a7f9bc2d904b5 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 17 Sep 2024 20:12:25 -0400 Subject: [PATCH] Flesh out GraphQL client --- .../AuthenticatedGraphQLServerClient.cs | 97 +++++++ .../AuthenticationException.cs | 51 ++++ .../AuthorizationMessageHandler.cs | 42 +++ .../GQL/Queries/OAuthInformation.graphql | 18 ++ .../GQL/Queries/ServerVersion.graphql | 11 + .../GraphQLServerClient.cs | 237 ++++++++++++++++- .../GraphQLServerClientFactory.cs | 175 +++++++++++-- .../IGraphQLServerClient.cs | 26 +- .../IGraphQLServerClientFactory.cs | 8 +- .../LoginResultExtensions.cs | 74 ++++++ .../Serializers/JwtSerializer.cs | 35 +++ .../Serializers/SemverSerializer.cs | 4 +- .../Serializers/UnsignedIntSerializer.cs | 2 +- .../Tgstation.Server.Client.GraphQL.csproj | 2 + .../schema.extensions.graphql | 1 + .../Authority/UserAuthority.cs | 4 + .../Mutations/Payloads/LoginPayload.cs | 2 + .../GraphQL/Types/Scalars/JwtType.cs | 38 +++ .../GraphQL/Types/Scalars/SemverType.cs | 2 +- .../Live/MultiServerClient.cs | 45 ++-- .../Live/RawRequestTests.cs | 6 +- .../Live/TestLiveServer.cs | 240 ++++++++++++------ 22 files changed, 982 insertions(+), 138 deletions(-) create mode 100644 src/Tgstation.Server.Client.GraphQL/AuthenticatedGraphQLServerClient.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/AuthenticationException.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/AuthorizationMessageHandler.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/OAuthInformation.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerVersion.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/LoginResultExtensions.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/Serializers/JwtSerializer.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/Scalars/JwtType.cs diff --git a/src/Tgstation.Server.Client.GraphQL/AuthenticatedGraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/AuthenticatedGraphQLServerClient.cs new file mode 100644 index 0000000000..8babf9765e --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/AuthenticatedGraphQLServerClient.cs @@ -0,0 +1,97 @@ +using System; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; + +using StrawberryShake; + +using Tgstation.Server.Common.Extensions; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + sealed class AuthenticatedGraphQLServerClient : GraphQLServerClient, IAuthenticatedGraphQLServerClient + { + /// + public ITransferClient TransferClient => restClient!.Transfer; + + /// + /// A that takes a bearer token as input and outputs a that uses it. + /// + readonly Func? getRestClientForToken; + + /// + /// The current . + /// + IRestServerClient? restClient; + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The value of . + public AuthenticatedGraphQLServerClient( + IGraphQLClient graphQLClient, + IAsyncDisposable serviceProvider, + ILogger logger, + IRestServerClient restClient) + : base(graphQLClient, serviceProvider, logger) + { + this.restClient = restClient ?? throw new ArgumentNullException(nameof(restClient)); + } + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The to call to set the async local for requests. + /// The basic to use for reauthentication. + /// The containing the initial JWT to use. + /// The value of . + public AuthenticatedGraphQLServerClient( + IGraphQLClient graphQLClient, + IAsyncDisposable serviceProvider, + ILogger logger, + Action setAuthenticationHeader, + AuthenticationHeaderValue? basicCredentialsHeader, + IOperationResult loginResult, + Func getRestClientForToken) + : base( + graphQLClient, + serviceProvider, + logger, + setAuthenticationHeader, + basicCredentialsHeader, + loginResult) + { + this.getRestClientForToken = getRestClientForToken ?? throw new ArgumentNullException(nameof(getRestClientForToken)); + restClient = getRestClientForToken(loginResult.Data!.Login.Bearer!.EncodedToken); + } + + /// + public sealed override ValueTask DisposeAsync() +#pragma warning disable CA2012 // Use ValueTasks correctly + => ValueTaskExtensions.WhenAll( + base.DisposeAsync(), + restClient!.DisposeAsync()); +#pragma warning restore CA2012 // Use ValueTasks correctly + + /// + protected sealed override async ValueTask CreateUpdatedAuthenticationHeader(string bearer) + { + var baseTask = base.CreateUpdatedAuthenticationHeader(bearer); + if (restClient != null) + await restClient.DisposeAsync().ConfigureAwait(false); + + if (getRestClientForToken != null) + restClient = getRestClientForToken(bearer); + + return await baseTask.ConfigureAwait(false); + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/AuthenticationException.cs b/src/Tgstation.Server.Client.GraphQL/AuthenticationException.cs new file mode 100644 index 0000000000..d02134f6e3 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/AuthenticationException.cs @@ -0,0 +1,51 @@ +using System; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// thrown when automatic authentication fails. + /// + public sealed class AuthenticationException : Exception + { + /// + /// The . + /// + public ILogin_Login_Errors_ErrorMessageError? ErrorMessage { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public AuthenticationException(ILogin_Login_Errors_ErrorMessageError errorMessage) + : base(errorMessage?.Message) + { + ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage)); + } + + /// + /// Initializes a new instance of the class. + /// + public AuthenticationException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + public AuthenticationException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public AuthenticationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/AuthorizationMessageHandler.cs b/src/Tgstation.Server.Client.GraphQL/AuthorizationMessageHandler.cs new file mode 100644 index 0000000000..949ac80f92 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/AuthorizationMessageHandler.cs @@ -0,0 +1,42 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// that applies the . + /// + sealed class AuthorizationMessageHandler : DelegatingHandler + { + /// + /// The to be applied. + /// + public static AsyncLocal Header { get; } = new AsyncLocal(); + + /// + /// override for . + /// + readonly AuthenticationHeaderValue? headerOverride; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public AuthorizationMessageHandler(AuthenticationHeaderValue? headerOverride) + { + this.headerOverride = headerOverride; + } + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var currentAuthHeader = headerOverride ?? Header.Value; + if (currentAuthHeader != null) + request.Headers.Authorization = currentAuthHeader; + + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/OAuthInformation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/OAuthInformation.graphql new file mode 100644 index 0000000000..e32a409b60 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/OAuthInformation.graphql @@ -0,0 +1,18 @@ +query OAuthInformation { + swarm { + currentNode { + gateway { + information { + oAuthProviderInfos { + key + value { + clientId + redirectUri + serverUrl + } + } + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerVersion.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerVersion.graphql new file mode 100644 index 0000000000..45dbf4ebbc --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerVersion.graphql @@ -0,0 +1,11 @@ +query ServerVersion { + swarm { + currentNode { + gateway { + information { + version + } + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs index b359139b69..60004ce1fd 100644 --- a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs @@ -1,11 +1,35 @@ using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net.Http.Headers; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +using StrawberryShake; + +using Tgstation.Server.Api; + namespace Tgstation.Server.Client.GraphQL { /// class GraphQLServerClient : IGraphQLServerClient { + /// + /// If the was initially authenticated. + /// + [MemberNotNullWhen(true, nameof(setAuthenticationHeader))] + [MemberNotNullWhen(true, nameof(bearerCredentialsTask))] + bool Authenticated => basicCredentialsHeader != null; + + /// + /// If the supports reauthentication. + /// + [MemberNotNullWhen(true, nameof(bearerCredentialsHeaderTaskLock))] + [MemberNotNullWhen(true, nameof(basicCredentialsHeader))] + bool CanReauthenticate => basicCredentialsHeader != null; + /// /// The for the . /// @@ -16,27 +40,232 @@ namespace Tgstation.Server.Client.GraphQL /// readonly IAsyncDisposable serviceProvider; + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// The which sets the for HTTP request in the current async context. + /// + readonly Action? setAuthenticationHeader; + + /// + /// The containing the authenticated user's password credentials. + /// + readonly AuthenticationHeaderValue? basicCredentialsHeader; + + /// + /// used to synchronize access to . + /// + readonly object? bearerCredentialsHeaderTaskLock; + + /// + /// A resulting in a containing the current for the and the it expires. + /// + Task<(AuthenticationHeaderValue Header, DateTime Exp)?>? bearerCredentialsTask; + + /// + /// Throws an for a login error that previously occured outside of the current call context. + /// + /// Always thrown. + [DoesNotReturn] + static void ThrowOtherCallerFailedAuthException() + => throw new AuthenticationException("Another caller failed to authenticate!"); + + /// + /// Checks if a given errored out with authentication errors. + /// + /// The . + /// if errored due to authentication issues, otherwise. + static bool IsAuthenticationError(IOperationResult operationResult) + => operationResult.Data == null + && operationResult.Errors.Any( + error => error.Extensions?.TryGetValue( + "code", + out object? codeExtension) == true + && codeExtension is string codeExtensionString + && codeExtensionString == "AUTH_NOT_AUTHENTICATED"); + /// /// Initializes a new instance of the class. /// /// The value of . /// The value of . + /// The value of . public GraphQLServerClient( IGraphQLClient graphQLClient, - IAsyncDisposable serviceProvider) + IAsyncDisposable serviceProvider, + ILogger logger) { this.graphQLClient = graphQLClient ?? throw new ArgumentNullException(nameof(graphQLClient)); this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The containing the initial JWT to use. + protected GraphQLServerClient( + IGraphQLClient graphQLClient, + IAsyncDisposable serviceProvider, + ILogger logger, + Action setAuthenticationHeader, + AuthenticationHeaderValue? basicCredentialsHeader, + IOperationResult loginResult) + : this(graphQLClient, serviceProvider, logger) + { + this.setAuthenticationHeader = setAuthenticationHeader ?? throw new ArgumentNullException(nameof(setAuthenticationHeader)); + ArgumentNullException.ThrowIfNull(loginResult); + this.basicCredentialsHeader = basicCredentialsHeader; + + var task = CreateCredentialsTuple(loginResult); + if (!task.IsCompleted) + throw new InvalidOperationException($"Expected {nameof(CreateCredentialsTuple)} to not await in constructor!"); + + bearerCredentialsTask = Task.FromResult<(AuthenticationHeaderValue Header, DateTime Exp)?>(task.Result); + + if (Authenticated) + bearerCredentialsHeaderTaskLock = new object(); } /// - public ValueTask DisposeAsync() => serviceProvider.DisposeAsync(); + public virtual ValueTask DisposeAsync() => serviceProvider.DisposeAsync(); /// - public virtual ValueTask RunQuery(Func queryExector) + public ValueTask> RunOperationAsync(Func>> queryExector, CancellationToken cancellationToken) + where TResultData : class { ArgumentNullException.ThrowIfNull(queryExector); - return queryExector(graphQLClient); + return WrapAuthentication(queryExector, cancellationToken); + } + + /// + public ValueTask> RunOperation(Func>> queryExector, CancellationToken cancellationToken) + where TResultData : class + { + ArgumentNullException.ThrowIfNull(queryExector); + return WrapAuthentication(async localClient => await queryExector(localClient), cancellationToken); + } + + /// + /// Create a from a given token. + /// + /// The . + /// A new . + protected virtual ValueTask CreateUpdatedAuthenticationHeader(string bearer) + => ValueTask.FromResult( + new AuthenticationHeaderValue( + ApiHeaders.BearerAuthenticationScheme, + bearer)); + + /// + /// Executes a given , potentially accounting for authentication issues. + /// + /// The of the 's . + /// A which executes a single query on a given and returns a resulting in the . + /// The for the operation. + /// A resulting in the . + async ValueTask> WrapAuthentication(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class + { + if (!Authenticated) + return await operationExecutor(graphQLClient).ConfigureAwait(false); + + var tuple = await bearerCredentialsTask.ConfigureAwait(false); + if (!tuple.HasValue) + ThrowOtherCallerFailedAuthException(); + + async ValueTask Reauthenticate(AuthenticationHeaderValue currentToken, CancellationToken cancellationToken) + { + if (!CanReauthenticate) + throw new AuthenticationException("Authentication expired or invalid and cannot re-authenticate."); + + TaskCompletionSource<(AuthenticationHeaderValue Header, DateTime Exp)?>? tcs = null; + do + { + var bearerCredentialsTaskLocal = bearerCredentialsTask; + if (!bearerCredentialsTaskLocal!.IsCompleted) + { + var currentTuple = await bearerCredentialsTaskLocal.ConfigureAwait(false); + if (!currentTuple.HasValue) + ThrowOtherCallerFailedAuthException(); + + return currentTuple.Value.Header; + } + + lock (bearerCredentialsHeaderTaskLock!) + { + if (bearerCredentialsTask == bearerCredentialsTaskLocal) + { + var result = bearerCredentialsTaskLocal.Result; + if (result?.Header != currentToken) + { + if (!result.HasValue) + ThrowOtherCallerFailedAuthException(); + + return result.Value.Header; + } + + tcs = new TaskCompletionSource<(AuthenticationHeaderValue, DateTime)?>(); + bearerCredentialsTask = tcs.Task; + } + } + } + while (tcs == null); + + setAuthenticationHeader!(basicCredentialsHeader!); + var loginResult = await graphQLClient.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false); + try + { + var tuple = await CreateCredentialsTuple(loginResult).ConfigureAwait(false); + tcs.SetResult(tuple); + return tuple.Header; + } + catch (AuthenticationException) + { + tcs.SetResult(null); + throw; + } + } + + var (currentAuthHeader, expires) = tuple.Value; + if (expires <= DateTimeOffset.UtcNow) + currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); + + setAuthenticationHeader(currentAuthHeader); + + var operationResult = await operationExecutor(graphQLClient); + + if (IsAuthenticationError(operationResult)) + { + currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); + setAuthenticationHeader(currentAuthHeader); + return await operationExecutor(graphQLClient); + } + + return operationResult; + } + + /// + /// Attempt to create the for . + /// + /// The to process. + /// A resulting in a new credentials . + /// Thrown if the errored. + async ValueTask<(AuthenticationHeaderValue Header, DateTime Exp)> CreateCredentialsTuple(IOperationResult loginResult) + { + var bearer = loginResult.EnsureSuccess(logger); + + var header = await CreateUpdatedAuthenticationHeader(bearer.EncodedToken); + + return (Header: header, Exp: bearer.ValidTo); } } } diff --git a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs index bbcd7f2f6e..50b602685d 100644 --- a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClientFactory.cs @@ -1,11 +1,18 @@ using System; +using System.Net.Http.Headers; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using StrawberryShake; using Tgstation.Server.Api; +using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Client.GraphQL.Serializers; +using Tgstation.Server.Common.Extensions; namespace Tgstation.Server.Client.GraphQL { @@ -17,6 +24,45 @@ namespace Tgstation.Server.Client.GraphQL /// readonly IRestServerClientFactory restClientFactory; + /// + /// Sets up a for providing the . + /// + /// The of the target tgstation-server. + /// If the should be configured. + /// The override for the . + /// The , if any. + /// A new . + static ServiceProvider SetupServiceProvider(Uri host, bool addAuthorizationHandler, AuthenticationHeaderValue? headerOverride = null, OAuthProvider? oAuthProvider = null) + { + var serviceCollection = new ServiceCollection(); + + var clientBuilder = serviceCollection + .AddGraphQLClient(); + var graphQLEndpoint = new Uri(host, Routes.GraphQL); + + clientBuilder.ConfigureHttpClient( + client => + { + client.BaseAddress = graphQLEndpoint; + client.DefaultRequestHeaders.Add(ApiHeaders.ApiVersionHeader, $"Tgstation.Server.Api/{ApiHeaders.Version.Semver()}"); + if (oAuthProvider.HasValue) + { + client.DefaultRequestHeaders.Add(ApiHeaders.OAuthProviderHeader, oAuthProvider.ToString()); + } + }, + clientBuilder => + { + if (addAuthorizationHandler) + clientBuilder.AddHttpMessageHandler(() => new AuthorizationMessageHandler(headerOverride)); + }); + + serviceCollection.AddSerializer(); + serviceCollection.AddSerializer(); + serviceCollection.AddSerializer(); + + return serviceCollection.BuildServiceProvider(); + } + /// /// Initializes a new instance of the class. /// @@ -29,39 +75,138 @@ namespace Tgstation.Server.Client.GraphQL /// public ValueTask CreateFromLogin(Uri host, string username, string password, bool attemptLoginRefresh = true, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + var basicCredentials = new AuthenticationHeaderValue( + ApiHeaders.BasicAuthenticationScheme, + Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{username}:{password}"))); + + return CreateWithAuthCall( + host, + basicCredentials, + null, + attemptLoginRefresh, + cancellationToken); } /// public ValueTask CreateFromOAuth(Uri host, string oAuthCode, OAuthProvider oAuthProvider, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + var oAuthCredentials = new AuthenticationHeaderValue( + ApiHeaders.OAuthAuthenticationScheme, + oAuthCode); + + return CreateWithAuthCall( + host, + oAuthCredentials, + oAuthProvider, + false, + cancellationToken); } /// public IAuthenticatedGraphQLServerClient CreateFromToken(Uri host, string token) { - throw new NotImplementedException(); + var authenticationHeader = new AuthenticationHeaderValue( + ApiHeaders.BearerAuthenticationScheme, + token); + + var serviceProvider = SetupServiceProvider( + host, + true, + authenticationHeader); + + return new AuthenticatedGraphQLServerClient( + serviceProvider.GetRequiredService(), + serviceProvider, + serviceProvider.GetRequiredService>(), + CreateAuthenticatedTransferClient(host, token)); } /// public IGraphQLServerClient CreateUnauthenticated(Uri host) { - var serviceCollection = new ServiceCollection(); - - var clientBuilder = serviceCollection - .AddGraphQLClient(); - var graphQLEndpoint = new Uri(host, Routes.GraphQL); - clientBuilder.ConfigureHttpClient(client => client.BaseAddress = graphQLEndpoint); - - serviceCollection.AddSerializer(); - serviceCollection.AddSerializer(); - - var serviceProvider = serviceCollection.BuildServiceProvider(); + var serviceProvider = SetupServiceProvider(host, false); return new GraphQLServerClient( serviceProvider.GetRequiredService(), - serviceProvider); + serviceProvider, + serviceProvider.GetRequiredService>()); + } + + /// + /// Create an from a remote login call. + /// + /// The URL to access TGS. + /// The initial to use to login. + /// The , if any. + /// If the client should attempt to renew its sessions with the . + /// Optional for the operation. + /// A resulting in a new . + /// Thrown when authentication fails. + async ValueTask CreateWithAuthCall( + Uri host, + AuthenticationHeaderValue initialCredentials, + OAuthProvider? oAuthProvider, + bool attemptLoginRefresh, + CancellationToken cancellationToken) + { + var serviceProvider = SetupServiceProvider( + host, + true, + oAuthProvider: oAuthProvider); + try + { + var client = serviceProvider.GetRequiredService(); + + IOperationResult result; + + var previousAuthHeader = AuthorizationMessageHandler.Header.Value; + AuthorizationMessageHandler.Header.Value = initialCredentials; + try + { + result = await client.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + AuthorizationMessageHandler.Header.Value = previousAuthHeader; + } + + var serverClient = new AuthenticatedGraphQLServerClient( + client, + serviceProvider, + serviceProvider.GetRequiredService>(), + newHeader => AuthorizationMessageHandler.Header.Value = newHeader, + attemptLoginRefresh ? initialCredentials : null, + result, + bearer => CreateAuthenticatedTransferClient(host, bearer)); + + await Task.Yield(); + + return serverClient; + } + catch + { + await serviceProvider.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + /// + /// Create a for a given and token. + /// + /// The URL to access TGS. + /// The bearer token to access the API with. + /// A new . + IRestServerClient CreateAuthenticatedTransferClient(Uri host, string bearer) + { + var restClient = restClientFactory.CreateFromToken( + host, + new TokenResponse + { + Bearer = bearer, + }); + + return restClient; } } } diff --git a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs index 8c6d2ff2fe..c20a606fa0 100644 --- a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs @@ -1,6 +1,9 @@ using System; +using System.Threading; using System.Threading.Tasks; +using StrawberryShake; + namespace Tgstation.Server.Client.GraphQL { /// @@ -9,10 +12,25 @@ namespace Tgstation.Server.Client.GraphQL public interface IGraphQLServerClient : IAsyncDisposable { /// - /// Runs a given . It may be invoked multiple times depending on the behavior of the . + /// Runs a given . It may be invoked multiple times depending on the behavior of the if reauthentication is required. /// - /// A which executes a single query on a given and returns a representing the running operation. - /// A representing the running operation. - ValueTask RunQuery(Func queryExector); + /// The of the 's . + /// A which executes a single query on a given and returns a resulting in the . + /// The for the operation. + /// A resulting in the . + /// Thrown when automatic reauthentication fails. + ValueTask> RunOperationAsync(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class; + + /// + /// Runs a given . It may be invoked multiple times depending on the behavior of the if reauthentication is required. + /// + /// The of the 's . + /// A which executes a single query on a given and returns a resulting in the . + /// The for the operation. + /// A resulting in the . + /// Thrown when automatic reauthentication fails. + ValueTask> RunOperation(Func>> operationExecutor, CancellationToken cancellationToken) + where TResultData : class; } } diff --git a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs index 06faa66adb..5ace7c2f8a 100644 --- a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs +++ b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClientFactory.cs @@ -2,8 +2,6 @@ using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Api.Models.Response; - namespace Tgstation.Server.Client.GraphQL { /// @@ -24,9 +22,10 @@ namespace Tgstation.Server.Client.GraphQL /// The URL to access TGS. /// The username to for the . /// The password for the . - /// Attempt to refresh the received when it expires or becomes invalid. and will be stored in memory if this is . + /// Attempt to refresh the received bearer token when it expires or becomes invalid. and will be stored in memory if this is . /// Optional for the operation. /// A resulting in a new . + /// Thrown when authentication fails. ValueTask CreateFromLogin( Uri host, string username, @@ -42,6 +41,7 @@ namespace Tgstation.Server.Client.GraphQL /// The . /// Optional for the operation. /// A resulting in a new . + /// Thrown when authentication fails. ValueTask CreateFromOAuth( Uri host, string oAuthCode, @@ -52,7 +52,7 @@ namespace Tgstation.Server.Client.GraphQL /// Create a . /// /// The URL to access TGS. - /// The to access the API with. + /// The bearer token to access the API with. /// A new . IAuthenticatedGraphQLServerClient CreateFromToken( Uri host, diff --git a/src/Tgstation.Server.Client.GraphQL/LoginResultExtensions.cs b/src/Tgstation.Server.Client.GraphQL/LoginResultExtensions.cs new file mode 100644 index 0000000000..5ec5360815 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/LoginResultExtensions.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; + +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.JsonWebTokens; +using StrawberryShake; + +namespace Tgstation.Server.Client.GraphQL +{ + /// + /// Extensions for . + /// + static class LoginResultExtensions + { + /// + /// Check a given for errors. + /// + /// The containing the . + /// The to write to. + /// The from the successful . + /// Thrown when the is errored. + public static JsonWebToken EnsureSuccess(this IOperationResult loginResult, ILogger logger) + { + ArgumentNullException.ThrowIfNull(loginResult); + + try + { + loginResult.EnsureNoErrors(); + } + catch (GraphQLClientException ex) + { + throw new AuthenticationException("Login attempt errored at the GraphQL level!", ex); + } + + var data = loginResult.Data!.Login; + var errors = data.Errors; + if (errors != null) + { + foreach (var error in errors) + { + if (error is ILogin_Login_Errors_ErrorMessageError errorMessageError) + logger.LogError( + "Authentication error ({code}): {message}{additionalData}", + errorMessageError.ErrorCode?.ToString() ?? "No Code", + errorMessageError.Message, + errorMessageError.AdditionalData != null + ? $"{Environment.NewLine}{errorMessageError.AdditionalData}" + : String.Empty); + else + logger.LogError( + "Unknown authentication error: {error}", + error); + } + } + + var bearer = data.Bearer; + if (bearer == null) + { + if (errors != null) + { + var errorMessage = errors.OfType().FirstOrDefault(); + if (errorMessage != null) + throw new AuthenticationException(errorMessage); + + throw new AuthenticationException($"Null bearer field and {errors.Count} non-ErrorMessage errors:{(errors.Count > 0 ? $"{Environment.NewLine}\t- {String.Join($"{Environment.NewLine}\t- ", errors)}" : String.Empty)}"); + } + + throw new AuthenticationException($"Null bearer and error fields!"); + } + + return bearer; + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/Serializers/JwtSerializer.cs b/src/Tgstation.Server.Client.GraphQL/Serializers/JwtSerializer.cs new file mode 100644 index 0000000000..41a0a960a2 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/Serializers/JwtSerializer.cs @@ -0,0 +1,35 @@ +using System; + +using Microsoft.IdentityModel.JsonWebTokens; + +using StrawberryShake.Serialization; + +#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql + +namespace Tgstation.Server.Client.GraphQL.Serializers +{ + /// + /// for s. + /// + sealed class JwtSerializer : ScalarSerializer + { + /// + /// Initializes a new instance of the class. + /// + public JwtSerializer() + : base("Jwt") + { + } + + /// + public override JsonWebToken Parse(string serializedValue) + => new(serializedValue ?? throw new ArgumentNullException(nameof(serializedValue))); + + /// + protected override string Format(JsonWebToken runtimeValue) + { + ArgumentNullException.ThrowIfNull(runtimeValue); + return runtimeValue.EncodedToken; + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs b/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs index cd7c6f378e..35fea8f509 100644 --- a/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs +++ b/src/Tgstation.Server.Client.GraphQL/Serializers/SemverSerializer.cs @@ -4,12 +4,12 @@ using StrawberryShake.Serialization; using Tgstation.Server.Common.Extensions; -#pragma warning disable CA1812 // not detecting service provider usage +#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql namespace Tgstation.Server.Client.GraphQL.Serializers { /// - /// for s. + /// for s. /// sealed class SemverSerializer : ScalarSerializer { diff --git a/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs b/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs index 8656438b11..fdc610d07c 100644 --- a/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs +++ b/src/Tgstation.Server.Client.GraphQL/Serializers/UnsignedIntSerializer.cs @@ -2,7 +2,7 @@ using StrawberryShake.Serialization; -#pragma warning disable CA1812 // not detecting service provider usage +#pragma warning disable CA1812 // not detecting usage via annotation in schema.extensions.graphql namespace Tgstation.Server.Client.GraphQL.Serializers { diff --git a/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj b/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj index 3299c2b4d9..4c1e5d1557 100644 --- a/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj +++ b/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj @@ -4,10 +4,12 @@ $(TgsFrameworkVersion) $(TgsApiVersion) + enable + diff --git a/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql index 023788fd33..8991e1fa70 100644 --- a/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql +++ b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql @@ -15,3 +15,4 @@ extend schema @key(fields: "id") extend scalar UnsignedInt @serializationType(name: "global::System.UInt32") @runtimeType(name: "global::System.UInt32") extend scalar Semver @serializationType(name: "global::System.String") @runtimeType(name: "global::System.Version") +extend scalar Jwt @serializationType(name: "global::System.String") @runtimeType(name: "global::Microsoft.IdentityModel.JsonWebTokens.JsonWebToken") diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index 40b48be96d..ed7dba0997 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -273,7 +273,11 @@ namespace Tgstation.Server.Host.Authority } /// +#pragma warning disable CA1502 +#pragma warning disable CA1506 // TODO: Decomplexify public async ValueTask> Update(UserUpdateRequest model, CancellationToken cancellationToken) +#pragma warning restore CA1502 +#pragma warning restore CA1506 { ArgumentNullException.ThrowIfNull(model); diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs index 5a055b6fcb..8d5cd5e683 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs @@ -1,5 +1,6 @@ using HotChocolate; using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.GraphQL.Types.Scalars; using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads @@ -12,6 +13,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads /// /// The JSON Web Token (JWT) to use as a Bearer token for accessing the server. Contains an expiry time. /// + [GraphQLType] public required string Bearer { get; init; } /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Scalars/JwtType.cs b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/JwtType.cs new file mode 100644 index 0000000000..47f10ccde3 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/JwtType.cs @@ -0,0 +1,38 @@ +using System; + +using HotChocolate.Language; +using HotChocolate.Types; + +namespace Tgstation.Server.Host.GraphQL.Types.Scalars +{ + /// + /// A for encoded JSON Web Tokens. + /// + public sealed class JwtType : ScalarType + { + /// + /// Initializes a new instance of the class. + /// + public JwtType() + : base("Jwt") + { + Description = "Represents an encoded JSON Web Token"; + SpecifiedBy = new Uri("https://datatracker.ietf.org/doc/html/rfc7519"); + } + + /// + public override IValueNode ParseResult(object? resultValue) + => ParseValue(resultValue); + + /// + protected override string ParseLiteral(StringValueNode valueSyntax) + { + ArgumentNullException.ThrowIfNull(valueSyntax); + return valueSyntax.Value; + } + + /// + protected override StringValueNode ParseValue(string runtimeValue) + => new(runtimeValue); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs index bd07aad71f..dce31b4751 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs @@ -62,7 +62,7 @@ namespace Tgstation.Server.Host.GraphQL.Types.Scalars /// protected override StringValueNode ParseValue(Version runtimeValue) - => new StringValueNode(runtimeValue.Semver().ToString()); + => new(runtimeValue.Semver().ToString()); /// protected override bool IsInstanceOfType(StringValueNode valueSyntax) diff --git a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs index 801f4451bd..3fd2b4b8f6 100644 --- a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs +++ b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs @@ -1,48 +1,57 @@ using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; +using StrawberryShake; + using Tgstation.Server.Client; using Tgstation.Server.Client.GraphQL; +using Tgstation.Server.Common.Extensions; namespace Tgstation.Server.Tests.Live { - sealed class MultiServerClient + sealed class MultiServerClient : IAsyncDisposable { - readonly IRestServerClient restServerClient; - readonly IGraphQLServerClient graphQLServerClient; - - readonly bool useGraphQL; + public IRestServerClient RestClient { get; } + public IGraphQLServerClient GraphQLClient { get; } public MultiServerClient(IRestServerClient restServerClient, IGraphQLServerClient graphQLServerClient) { - this.restServerClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient)); - this.graphQLServerClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient)); - this.useGraphQL = Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result; + RestClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient)); + GraphQLClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient)); } + public static bool UseGraphQL => Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result; + + public ValueTask DisposeAsync() + => ValueTaskExtensions.WhenAll( + RestClient.DisposeAsync(), + GraphQLClient.DisposeAsync()); + public ValueTask Execute( Func restAction, - Func graphQLAction) + Func graphQLAction) { - if (useGraphQL) - return graphQLServerClient.RunQuery(graphQLAction); + if (UseGraphQL) + return graphQLAction(GraphQLClient); - return restAction(restServerClient); + return restAction(RestClient); } public async ValueTask ExecuteReadOnlyConfirmEquivalence( Func> restAction, - Func> graphQLAction, - Func comparison) + Func>> graphQLAction, + Func comparison, + CancellationToken cancellationToken) + where TGraphQLResult : class { - var restTask = restAction(this.restServerClient); - TGraphQLResult graphQLResult = default; - await this.graphQLServerClient.RunQuery(async gqlClient => graphQLResult = await graphQLAction(gqlClient)); + var restTask = restAction(RestClient); + var graphQLResult = await GraphQLClient.RunOperation(graphQLAction, cancellationToken); var restResult = await restTask; - Assert.IsTrue(comparison(restResult, graphQLResult), "REST/GraphQL results differ!"); + Assert.IsTrue(comparison(restResult, graphQLResult.Data), "REST/GraphQL results differ!"); } } } diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index bac0433d4f..240a89c245 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -463,11 +463,7 @@ namespace Tgstation.Server.Tests.Live static async Task TestGraphQLLogin(IRestServerClientFactory clientFactory, IRestServerClient restClient, CancellationToken cancellationToken) { await using var gqlClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(restClient.Url); - IOperationResult result = null; - await gqlClient.RunQuery(async client => - { - result = await client.Login.ExecuteAsync(cancellationToken); - }); + var result = await gqlClient.RunOperation(client => client.Login.ExecuteAsync(cancellationToken), cancellationToken); Assert.IsNotNull(result.Data); Assert.IsNull(result.Data.Login.Bearer); diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 8576950da5..e47fd8c822 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -27,6 +27,8 @@ using Newtonsoft.Json; using Npgsql; +using StrawberryShake; + using Tgstation.Server.Api; using Tgstation.Server.Api.Extensions; using Tgstation.Server.Api.Models; @@ -73,7 +75,14 @@ namespace Tgstation.Server.Tests.Live _ = mainDMPort.Value; } - readonly RestServerClientFactory clientFactory = new (new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString())); + readonly RestServerClientFactory restClientFactory; + readonly GraphQLServerClientFactory graphQLClientFactory; + + public TestLiveServer() + { + restClientFactory = new(new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString())); + graphQLClientFactory = new GraphQLServerClientFactory(restClientFactory); + } public static List GetEngineServerProcessesOnPort(EngineType engineType, ushort? port) { @@ -295,7 +304,7 @@ namespace Tgstation.Server.Tests.Live request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version); - request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, adminClient.Token.Bearer); + request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, adminClient.RestClient.Token.Bearer); request.Headers.Add(ApiHeaders.OAuthProviderHeader, Api.Models.OAuthProvider.GitHub.ToString()); using var response = await httpClient.SendAsync(request, cancellationToken); Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); @@ -306,7 +315,7 @@ namespace Tgstation.Server.Tests.Live //attempt to update to stable var responseModel = await TestWithoutAndWithPermission( - () => adminClient.Administration.Update( + () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion, @@ -314,7 +323,7 @@ namespace Tgstation.Server.Tests.Live }, null, cancellationToken), - adminClient, + adminClient.RestClient, AdministrationRights.ChangeVersion); Assert.IsNotNull(responseModel); @@ -323,7 +332,7 @@ namespace Tgstation.Server.Tests.Live try { - var serverInfoTask = adminClient.ServerInformation(cancellationToken).AsTask(); + var serverInfoTask = adminClient.RestClient.ServerInformation(cancellationToken).AsTask(); var completedTask = await Task.WhenAny(serverTask, serverInfoTask); if (completedTask == serverInfoTask) { @@ -376,7 +385,7 @@ namespace Tgstation.Server.Tests.Live var downloadStream = await download.GetResult(cancellationToken); var responseModel = await TestWithoutAndWithPermission( - () => adminClient.Administration.Update( + () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion, @@ -384,7 +393,7 @@ namespace Tgstation.Server.Tests.Live }, downloadStream, cancellationToken), - adminClient, + adminClient.RestClient, AdministrationRights.UploadVersion); Assert.IsNotNull(responseModel); @@ -424,7 +433,7 @@ namespace Tgstation.Server.Tests.Live var testUpdateVersion = new Version(5, 11, 20); await using var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken); await ApiAssert.ThrowsException( - () => adminClient.Administration.Update( + () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = testUpdateVersion @@ -474,7 +483,7 @@ namespace Tgstation.Server.Tests.Live { await using var controllerClient = await CreateAdminClient(controller.ApiUrl, cancellationToken); - var controllerInfo = await controllerClient.ServerInformation(cancellationToken); + var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); static void CheckInfo(ServerInformationResponse serverInformation) { @@ -489,7 +498,7 @@ namespace Tgstation.Server.Tests.Live CheckInfo(controllerInfo); // test update - var responseModel = await controllerClient.Administration.Update( + var responseModel = await controllerClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion @@ -577,7 +586,7 @@ namespace Tgstation.Server.Tests.Live await using var node1Client = await CreateAdminClient(node1.ApiUrl, cancellationToken); await using var node2Client = await CreateAdminClient(node2.ApiUrl, cancellationToken); - var controllerInfo = await controllerClient.ServerInformation(cancellationToken); + var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); async Task WaitForSwarmServerUpdate() { @@ -585,7 +594,7 @@ namespace Tgstation.Server.Tests.Live do { await Task.Delay(TimeSpan.FromSeconds(10)); - serverInformation = await node1Client.ServerInformation(cancellationToken); + serverInformation = await node1Client.RestClient.ServerInformation(cancellationToken); } while (serverInformation.SwarmServers.Count == 1); } @@ -618,13 +627,13 @@ namespace Tgstation.Server.Tests.Live WaitForSwarmServerUpdate(), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - var node2Info = await node2Client.ServerInformation(cancellationToken); - var node1Info = await node1Client.ServerInformation(cancellationToken); + var node2Info = await node2Client.RestClient.ServerInformation(cancellationToken); + var node1Info = await node1Client.RestClient.ServerInformation(cancellationToken); CheckInfo(node1Info); CheckInfo(node2Info); // check user info is shared - var newUser = await node2Client.Users.Create(new UserCreateRequest + var newUser = await node2Client.RestClient.Users.Create(new UserCreateRequest { Name = "asdf", Password = "asdfasdfasdfasdf", @@ -635,20 +644,20 @@ namespace Tgstation.Server.Tests.Live } }, cancellationToken); - var node1User = await node1Client.Users.GetId(newUser, cancellationToken); + var node1User = await node1Client.RestClient.Users.GetId(newUser, cancellationToken); Assert.AreEqual(newUser.Name, node1User.Name); Assert.AreEqual(newUser.Enabled, node1User.Enabled); - await using var controllerUserClient = await clientFactory.CreateFromLogin( + await using var controllerUserClient = await restClientFactory.CreateFromLogin( controllerAddress, newUser.Name, "asdfasdfasdfasdf"); - await using var node1TokenCopiedClient = clientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token); + await using var node1TokenCopiedClient = restClientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token); await node1TokenCopiedClient.Administration.Read(false, cancellationToken); // check instance info is not shared - var controllerInstance = await controllerClient.Instances.CreateOrAttach( + var controllerInstance = await controllerClient.RestClient.Instances.CreateOrAttach( new InstanceCreateRequest { Name = "ControllerInstance", @@ -656,27 +665,27 @@ namespace Tgstation.Server.Tests.Live }, cancellationToken); - var node2Instance = await node2Client.Instances.CreateOrAttach( + var node2Instance = await node2Client.RestClient.Instances.CreateOrAttach( new InstanceCreateRequest { Name = "Node2Instance", Path = Path.Combine(node2.Directory, "Node2Instance") }, cancellationToken); - var node2InstanceList = await node2Client.Instances.List(null, cancellationToken); + var node2InstanceList = await node2Client.RestClient.Instances.List(null, cancellationToken); Assert.AreEqual(1, node2InstanceList.Count); Assert.AreEqual(node2Instance.Id, node2InstanceList[0].Id); - Assert.IsNotNull(await node2Client.Instances.GetId(node2Instance, cancellationToken)); - var controllerInstanceList = await controllerClient.Instances.List(null, cancellationToken); + Assert.IsNotNull(await node2Client.RestClient.Instances.GetId(node2Instance, cancellationToken)); + var controllerInstanceList = await controllerClient.RestClient.Instances.List(null, cancellationToken); Assert.AreEqual(1, controllerInstanceList.Count); Assert.AreEqual(controllerInstance.Id, controllerInstanceList[0].Id); - Assert.IsNotNull(await controllerClient.Instances.GetId(controllerInstance, cancellationToken)); + Assert.IsNotNull(await controllerClient.RestClient.Instances.GetId(controllerInstance, cancellationToken)); - await ApiAssert.ThrowsException(() => controllerClient.Instances.GetId(node2Instance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); - await ApiAssert.ThrowsException(() => node1Client.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); + await ApiAssert.ThrowsException(() => controllerClient.RestClient.Instances.GetId(node2Instance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); + await ApiAssert.ThrowsException(() => node1Client.RestClient.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); // test update - await node1Client.Administration.Update( + await node1Client.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion @@ -717,7 +726,7 @@ namespace Tgstation.Server.Tests.Live await using var controllerClient2 = await CreateAdminClient(controller.ApiUrl, cancellationToken); await using var node1Client2 = await CreateAdminClient(node1.ApiUrl, cancellationToken); - await ApiAssert.ThrowsException(() => controllerClient2.Administration.Update( + await ApiAssert.ThrowsException(() => controllerClient2.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion @@ -738,7 +747,7 @@ namespace Tgstation.Server.Tests.Live do { await Task.Delay(TimeSpan.FromSeconds(10)); - serverInformation = await node2Client2.ServerInformation(cancellationToken); + serverInformation = await node2Client2.RestClient.ServerInformation(cancellationToken); } while (serverInformation.SwarmServers.Count == 1); } @@ -747,8 +756,8 @@ namespace Tgstation.Server.Tests.Live WaitForSwarmServerUpdate2(), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - var node2Info2 = await node2Client2.ServerInformation(cancellationToken); - var node1Info2 = await node1Client2.ServerInformation(cancellationToken); + var node2Info2 = await node2Client2.RestClient.ServerInformation(cancellationToken); + var node1Info2 = await node1Client2.RestClient.ServerInformation(cancellationToken); CheckInfo(node1Info2); CheckInfo(node2Info2); @@ -765,7 +774,7 @@ namespace Tgstation.Server.Tests.Live gitHubToken); var downloadStream = await download.GetResult(cancellationToken); - var responseModel = await controllerClient2.Administration.Update( + var responseModel = await controllerClient2.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion, @@ -848,7 +857,7 @@ namespace Tgstation.Server.Tests.Live await using var node1Client = await CreateAdminClient(node1.ApiUrl, cancellationToken); await using var node2Client = await CreateAdminClient(node2.ApiUrl, cancellationToken); - var controllerInfo = await controllerClient.ServerInformation(cancellationToken); + var controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); async Task WaitForSwarmServerUpdate(IRestServerClient client, int currentServerCount) { @@ -889,11 +898,11 @@ namespace Tgstation.Server.Tests.Live // wait a few minutes for the updated server list to dispatch await Task.WhenAny( - WaitForSwarmServerUpdate(node1Client, 1), + WaitForSwarmServerUpdate(node1Client.RestClient, 1), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - var node2Info = await node2Client.ServerInformation(cancellationToken); - var node1Info = await node1Client.ServerInformation(cancellationToken); + var node2Info = await node2Client.RestClient.ServerInformation(cancellationToken); + var node1Info = await node1Client.RestClient.ServerInformation(cancellationToken); CheckInfo(node1Info); CheckInfo(node2Info); @@ -905,21 +914,21 @@ namespace Tgstation.Server.Tests.Live Assert.IsTrue(node1Task.IsCompleted); // it should unregister - controllerInfo = await controllerClient.ServerInformation(cancellationToken); + controllerInfo = await controllerClient.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, controllerInfo.SwarmServers.Count); Assert.IsFalse(controllerInfo.SwarmServers.Any(x => x.Identifier == "node1")); // wait a few minutes for the updated server list to dispatch await Task.WhenAny( - WaitForSwarmServerUpdate(node2Client, 3), + WaitForSwarmServerUpdate(node2Client.RestClient, 3), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - node2Info = await node2Client.ServerInformation(cancellationToken); + node2Info = await node2Client.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, node2Info.SwarmServers.Count); Assert.IsFalse(node2Info.SwarmServers.Any(x => x.Identifier == "node1")); // restart the controller - await controllerClient.Administration.Restart(cancellationToken); + await controllerClient.RestClient.Administration.Restart(cancellationToken); await Task.WhenAny( controllerTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -930,10 +939,10 @@ namespace Tgstation.Server.Tests.Live // node 2 should reconnect once it's health check triggers await Task.WhenAny( - WaitForSwarmServerUpdate(controllerClient2, 1), + WaitForSwarmServerUpdate(controllerClient2.RestClient, 1), Task.Delay(TimeSpan.FromMinutes(5), cancellationToken)); - controllerInfo = await controllerClient2.ServerInformation(cancellationToken); + controllerInfo = await controllerClient2.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, controllerInfo.SwarmServers.Count); Assert.IsNotNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2")); @@ -941,20 +950,20 @@ namespace Tgstation.Server.Tests.Live await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); // restart node2 - await node2Client.Administration.Restart(cancellationToken); + await node2Client.RestClient.Administration.Restart(cancellationToken); await Task.WhenAny( node2Task, Task.Delay(TimeSpan.FromMinutes(1))); Assert.IsTrue(node1Task.IsCompleted); // should have unregistered - controllerInfo = await controllerClient2.ServerInformation(cancellationToken); + controllerInfo = await controllerClient2.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(1, controllerInfo.SwarmServers.Count); Assert.IsNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2")); // update should fail await ApiAssert.ThrowsException( - () => controllerClient2.Administration.Update(new ServerUpdateRequest + () => controllerClient2.RestClient.Administration.Update(new ServerUpdateRequest { NewVersion = TestUpdateVersion }, @@ -967,10 +976,10 @@ namespace Tgstation.Server.Tests.Live // should re-register await Task.WhenAny( - WaitForSwarmServerUpdate(node2Client2, 1), + WaitForSwarmServerUpdate(node2Client2.RestClient, 1), Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); - node2Info = await node2Client2.ServerInformation(cancellationToken); + node2Info = await node2Client2.RestClient.ServerInformation(cancellationToken); Assert.AreEqual(2, node2Info.SwarmServers.Count); Assert.IsNotNull(node2Info.SwarmServers.SingleOrDefault(x => x.Identifier == "controller")); } @@ -1021,10 +1030,10 @@ namespace Tgstation.Server.Tests.Live try { await using var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken); - - var instanceManagerTest = new InstanceManagerTest(adminClient, server.Directory); + var restAdminClient = adminClient.RestClient; + var instanceManagerTest = new InstanceManagerTest(restAdminClient, server.Directory); var instance = await instanceManagerTest.CreateTestInstance("TgTestInstance", cancellationToken); - var instanceClient = adminClient.Instances.CreateClient(instance); + var instanceClient = restAdminClient.Instances.CreateClient(instance); var ddUpdateTask = instanceClient.DreamDaemon.Update(new DreamDaemonRequest @@ -1327,34 +1336,48 @@ namespace Tgstation.Server.Tests.Live var serverTask = server.Run(cancellationToken).AsTask(); var fileDownloader = ((Host.Server)server.RealServer).Host.Services.GetRequiredService(); + var graphQLClientFactory = new GraphQLServerClientFactory(restClientFactory); try { Api.Models.Instance instance; long initialStaged, initialActive, initialSessionId; - await using var firstAdminClient = await CreateAdminClient(server.ApiUrl, cancellationToken); - await using (var tokenOnlyClient = clientFactory.CreateFromToken(server.RootUrl, firstAdminClient.Token)) + await using var firstAdminMultiClient = await CreateAdminClient(server.ApiUrl, cancellationToken); + + var firstAdminRestClient = firstAdminMultiClient.RestClient; + await using (var tokenOnlyRestClient = restClientFactory.CreateFromToken(server.RootUrl, firstAdminRestClient.Token)) { // regression test for password change issue - var currentUser = await tokenOnlyClient.Users.Read(cancellationToken); - var updatedUser = await tokenOnlyClient.Users.Update(new UserUpdateRequest + var currentUser = await tokenOnlyRestClient.Users.Read(cancellationToken); + var updatedUser = await tokenOnlyRestClient.Users.Update(new UserUpdateRequest { Id = currentUser.Id, Password = DefaultCredentials.DefaultAdminUserPassword, }, cancellationToken); - await ApiAssert.ThrowsException(() => tokenOnlyClient.Users.Read(cancellationToken), null); + await ApiAssert.ThrowsException(() => tokenOnlyRestClient.Users.Read(cancellationToken), null); + } + + await using (var tokenOnlyGraphQLClient = graphQLClientFactory.CreateFromToken(server.RootUrl, firstAdminRestClient.Token.Bearer)) + { + // just testing auth works the same here + var result = await tokenOnlyGraphQLClient.RunOperation(client => client.ServerVersion.ExecuteAsync(cancellationToken), cancellationToken); + Assert.IsTrue(result.IsSuccessResult()); } // basic graphql test, to be used everywhere eventually - await using (var graphQLClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(server.RootUrl)) + await using (var unauthenticatedGraphQLClient = graphQLClientFactory.CreateUnauthenticated(server.RootUrl)) { - // test getting server info - var multiClient = new MultiServerClient(firstAdminClient, graphQLClient); + // check auth works as expected + var result = await unauthenticatedGraphQLClient.RunOperation(client => client.ServerVersion.ExecuteAsync(cancellationToken), cancellationToken); + Assert.IsTrue(result.IsErrorResult()); - await multiClient.ExecuteReadOnlyConfirmEquivalence( + // test getting server info + var unAuthedMultiClient = new MultiServerClient(firstAdminRestClient, unauthenticatedGraphQLClient); + + await unAuthedMultiClient.ExecuteReadOnlyConfirmEquivalence( restClient => restClient.ServerInformation(cancellationToken), - async gqlClient => (await gqlClient.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken)).Data, + gqlClient => gqlClient.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken), (restServerInfo, gqlServerInfo) => restServerInfo.ApiVersion.Major == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.MajorApiVersion && (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.OAuthProviderInfos || restServerInfo.OAuthProviderInfos.All(kvp => @@ -1364,7 +1387,8 @@ namespace Tgstation.Server.Tests.Live && info.Value.ServerUrl == kvp.Value.ServerUrl && info.Value.ClientId == kvp.Value.ClientId && info.Value.RedirectUri == kvp.Value.RedirectUri; - }))); + })), + cancellationToken); } async ValueTask CreateUserWithNoInstancePerms() @@ -1380,13 +1404,13 @@ namespace Tgstation.Server.Tests.Live } }; - var user = await firstAdminClient.Users.Create(createRequest, cancellationToken); + var user = await firstAdminRestClient.Users.Create(createRequest, cancellationToken); Assert.IsTrue(user.Enabled); - return await clientFactory.CreateFromLogin(server.RootUrl, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken); + return await restClientFactory.CreateFromLogin(server.RootUrl, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken); } - var jobsHubTest = new JobsHubTests(firstAdminClient, await CreateUserWithNoInstancePerms()); + var jobsHubTest = new JobsHubTests(firstAdminRestClient, await CreateUserWithNoInstancePerms()); Task jobsHubTestTask; { if (server.DumpOpenApiSpecpath) @@ -1422,11 +1446,11 @@ namespace Tgstation.Server.Tests.Live if (!openDreamOnly) { jobsHubTestTask = FailFast(await jobsHubTest.Run(cancellationToken)); // returns Task - var rootTest = FailFast(RawRequestTests.Run(clientFactory, firstAdminClient, cancellationToken)); - var adminTest = FailFast(new AdministrationTest(firstAdminClient.Administration).Run(cancellationToken)); - var usersTest = FailFast(new UsersTest(firstAdminClient).Run(cancellationToken)); + var rootTest = FailFast(RawRequestTests.Run(restClientFactory, firstAdminRestClient, cancellationToken)); + var adminTest = FailFast(new AdministrationTest(firstAdminRestClient.Administration).Run(cancellationToken)); + var usersTest = FailFast(new UsersTest(firstAdminRestClient).Run(cancellationToken)); - var instanceManagerTest = new InstanceManagerTest(firstAdminClient, server.Directory); + var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory); var compatInstanceTask = instanceManagerTest.CreateTestInstance("CompatTestsInstance", cancellationToken); var odInstanceTask = instanceManagerTest.CreateTestInstance("OdTestsInstance", cancellationToken); var byondApiCompatInstanceTask = instanceManagerTest.CreateTestInstance("BCAPITestsInstance", cancellationToken); @@ -1436,7 +1460,7 @@ namespace Tgstation.Server.Tests.Live var byondApiCompatInstance = await byondApiCompatInstanceTask; var instancesTest = FailFast(instanceManagerTest.RunPreTest(cancellationToken)); Assert.IsTrue(Directory.Exists(instance.Path)); - instanceClient = firstAdminClient.Instances.CreateClient(instance); + instanceClient = firstAdminRestClient.Instances.CreateClient(instance); Assert.IsTrue(Directory.Exists(instanceClient.Metadata.Path)); nonInstanceTests = Task.WhenAll(instancesTest, adminTest, rootTest, usersTest); @@ -1447,13 +1471,13 @@ namespace Tgstation.Server.Tests.Live nonInstanceTests = Task.CompletedTask; jobsHubTestTask = null; instance = null; - var instanceManagerTest = new InstanceManagerTest(firstAdminClient, server.Directory); + var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory); var odInstanceTask = instanceManagerTest.CreateTestInstance("OdTestsInstance", cancellationToken); odInstance = await odInstanceTask; } var instanceTest = new InstanceTest( - firstAdminClient.Instances, + firstAdminRestClient.Instances, fileDownloader, GetInstanceManager(), (ushort)server.ApiUrl.Port); @@ -1482,7 +1506,7 @@ namespace Tgstation.Server.Tests.Live .RunCompatTests( await edgeODVersionTask, server.OpenDreamUrl, - firstAdminClient.Instances.CreateClient(odInstance), + firstAdminRestClient.Instances.CreateClient(odInstance), odDMPort.Value, odDDPort.Value, server.HighPriorityDreamDaemon, @@ -1509,7 +1533,7 @@ namespace Tgstation.Server.Tests.Live : new Version(512, 1451) // http://www.byond.com/forum/?forum=5&command=search&scope=local&text=resolved%3a512.1451 }, server.OpenDreamUrl, - firstAdminClient.Instances.CreateClient(compatInstance), + firstAdminRestClient.Instances.CreateClient(compatInstance), compatDMPort.Value, compatDDPort.Value, server.HighPriorityDreamDaemon, @@ -1550,7 +1574,7 @@ namespace Tgstation.Server.Tests.Live initialSessionId = dd.SessionId.Value; jobsHubTest.ExpectShutdown(); - await firstAdminClient.Administration.Restart(cancellationToken); + await firstAdminRestClient.Administration.Restart(cancellationToken); } await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -1599,8 +1623,10 @@ namespace Tgstation.Server.Tests.Live // chat bot start and DD reattach test serverTask = server.Run(cancellationToken).AsTask(); - await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) + await using (var multiClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) { + var adminClient = multiClient.RestClient; + await jobsHubTest.WaitForReconnect(cancellationToken); var instanceClient = adminClient.Instances.CreateClient(instance); @@ -1704,8 +1730,9 @@ namespace Tgstation.Server.Tests.Live var edgeVersion = await EngineTest.GetEdgeVersion(EngineType.Byond, fileDownloader, cancellationToken); await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) { + var restAdminClient = adminClient.RestClient; await jobsHubTest.WaitForReconnect(cancellationToken); - var instanceClient = adminClient.Instances.CreateClient(instance); + var instanceClient = restAdminClient.Instances.CreateClient(instance); await WaitForInitialJobs(instanceClient); var dd = await instanceClient.DreamDaemon.Read(cancellationToken); @@ -1737,7 +1764,7 @@ namespace Tgstation.Server.Tests.Live expectedStaged = compileJob.Id.Value; jobsHubTest.ExpectShutdown(); - await adminClient.Administration.Restart(cancellationToken); + await restAdminClient.Administration.Restart(cancellationToken); } await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -1747,8 +1774,9 @@ namespace Tgstation.Server.Tests.Live serverTask = server.Run(cancellationToken).AsTask(); await using (var adminClient = await CreateAdminClient(server.ApiUrl, cancellationToken)) { + var restAdminClient = adminClient.RestClient; await jobsHubTest.WaitForReconnect(cancellationToken); - var instanceClient = adminClient.Instances.CreateClient(instance); + var instanceClient = restAdminClient.Instances.CreateClient(instance); await WaitForInitialJobs(instanceClient); var currentDD = await instanceClient.DreamDaemon.Read(cancellationToken); @@ -1763,7 +1791,7 @@ namespace Tgstation.Server.Tests.Live await using var repoTestObj = new RepositoryTest(instanceClient.Repository, instanceClient.Jobs); var repoTest = repoTestObj.RunPostTest(cancellationToken); - await using var chatTestObj = new ChatTest(instanceClient.ChatBots, adminClient.Instances, instanceClient.Jobs, instance); + await using var chatTestObj = new ChatTest(instanceClient.ChatBots, restAdminClient.Instances, instanceClient.Jobs, instance); await chatTestObj.RunPostTest(cancellationToken); await repoTest; @@ -1772,7 +1800,7 @@ namespace Tgstation.Server.Tests.Live jobsHubTest.CompleteNow(); await jobsHubTestTask; - await new InstanceManagerTest(adminClient, server.Directory).RunPostTest(instance, cancellationToken); + await new InstanceManagerTest(restAdminClient, server.Directory).RunPostTest(instance, cancellationToken); } } catch (ApiException ex) @@ -1807,20 +1835,64 @@ namespace Tgstation.Server.Tests.Live await serverTask; } - async Task CreateAdminClient(Uri url, CancellationToken cancellationToken) + async ValueTask CreateAdminClient(Uri url, CancellationToken cancellationToken) { url = new Uri(url.ToString().Replace(Routes.ApiRoot, String.Empty)); var giveUpAt = DateTimeOffset.UtcNow.AddMinutes(2); for (var I = 1; ; ++I) { + ValueTask restClientTask; + ValueTask graphQLClientTask; try { - System.Console.WriteLine($"TEST: CreateAdminClient attempt {I}..."); - return await clientFactory.CreateFromLogin( + Console.WriteLine($"TEST: CreateAdminClient attempt {I}..."); + + restClientTask = restClientFactory.CreateFromLogin( url, DefaultCredentials.AdminUserName, DefaultCredentials.DefaultAdminUserPassword, cancellationToken: cancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + graphQLClientTask = graphQLClientFactory.CreateFromLogin( + url, + DefaultCredentials.AdminUserName, + DefaultCredentials.DefaultAdminUserPassword, + cancellationToken: cts.Token); + + IRestServerClient restClient; + try + { + restClient = await restClientTask; + } + catch (Exception restException) when (restException is not HttpRequestException && restException is not ServiceUnavailableException) + { + cts.Cancel(); + try + { + await (await graphQLClientTask).DisposeAsync(); + } + catch (OperationCanceledException) + { + } + catch (Exception graphQLException) + { + throw new AggregateException(restException, graphQLException); + } + + throw; + } + + try + { + return new MultiServerClient( + restClient, + await graphQLClientTask); + } + catch + { + await restClient.DisposeAsync(); + throw; + } } catch (HttpRequestException) {