diff --git a/src/Speckle.Sdk/Credentials/Account.cs b/src/Speckle.Sdk/Credentials/Account.cs index a9dbbbe2..dc3a92a2 100644 --- a/src/Speckle.Sdk/Credentials/Account.cs +++ b/src/Speckle.Sdk/Credentials/Account.cs @@ -1,11 +1,8 @@ -using System.Runtime.InteropServices; using Speckle.Sdk.Api.GraphQL.Models; using Speckle.Sdk.Common; namespace Speckle.Sdk.Credentials; -[ClassInterface(ClassInterfaceType.AutoDual)] -[ComVisible(true)] public class Account : IEquatable { private string _id; @@ -37,6 +34,8 @@ public class Account : IEquatable public string? refreshToken { get; set; } public bool isDefault { get; set; } + + [Obsolete("Not used in v3")] public bool isOnline { get; set; } = true; public ServerInfo serverInfo { get; set; } @@ -101,33 +100,4 @@ public class Account : IEquatable } #endregion - - internal const string LOCAL_IDENTIFIER_DEPRECATION_MESSAGE = "Local identifiers no longer nesseary"; - - /// - /// Retrieves the local identifier for the current user. - /// - /// - /// Returns a object representing the local identifier for the current user. - /// The local identifier is created by appending the user ID as a query parameter to the server URL. - /// - /// - /// Notice that the generated Uri is not intended to be used as a functioning Uri, but rather as a - /// unique identifier for a specific account in a local environment. The format of the Uri, containing a query parameter with the user ID, - /// serves this specific purpose. Therefore, it should not be used for forming network requests or - /// expecting it to lead to an actual webpage. The primary intent of this Uri is for unique identification in a Uri format. - /// - /// - /// This sample shows how to call the GetLocalIdentifier method. - /// - /// Uri localIdentifier = GetLocalIdentifier(); - /// Console.WriteLine(localIdentifier); - /// - /// For a fictional `User ID: 123` and `Server: https://speckle.xyz`, the output might look like this: - /// - /// https://speckle.xyz?id=123 - /// - /// - [Obsolete(LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)] - internal Uri GetLocalIdentifier() => new($"{serverInfo.url}?id={userInfo.id}"); } diff --git a/src/Speckle.Sdk/Credentials/AccountManager.cs b/src/Speckle.Sdk/Credentials/AccountManager.cs index 7fb97826..d20a3473 100644 --- a/src/Speckle.Sdk/Credentials/AccountManager.cs +++ b/src/Speckle.Sdk/Credentials/AccountManager.cs @@ -1,145 +1,35 @@ -using System.Diagnostics; -using System.Net; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.RegularExpressions; -using GraphQL; using GraphQL.Client.Http; using Microsoft.Extensions.Logging; using Speckle.InterfaceGenerator; using Speckle.Newtonsoft.Json; -using Speckle.Sdk.Api.GraphQL; using Speckle.Sdk.Api.GraphQL.Models; -using Speckle.Sdk.Api.GraphQL.Models.Responses; using Speckle.Sdk.Common; -using Speckle.Sdk.Helpers; using Speckle.Sdk.Logging; using Speckle.Sdk.SQLite; -using Stream = System.IO.Stream; namespace Speckle.Sdk.Credentials; public partial interface IAccountManager : IDisposable; /// -/// Manage accounts locally for desktop applications. +/// Manages data in the local sqlite account store /// [GenerateAutoInterface] public sealed class AccountManager( - ISpeckleApplication application, ILogger logger, - IGraphQLClientFactory graphQLClientFactory, - ISpeckleHttp speckleHttp, IAccountFactory accountFactory, + IAuthFlow authFlow, ISqLiteJsonCacheManagerFactory sqLiteJsonCacheManagerFactory ) : IAccountManager { public const string DEFAULT_SERVER_URL = "https://app.speckle.systems"; private readonly ISqLiteJsonCacheManager _accountStorage = sqLiteJsonCacheManagerFactory.CreateForUser("Accounts"); - private static volatile bool s_isAddingAccount; - private readonly ISqLiteJsonCacheManager _accountAddLockStorage = sqLiteJsonCacheManagerFactory.CreateForUser( - "AccountAddFlow" - ); [AutoInterfaceIgnore] public void Dispose() { _accountStorage.Dispose(); - _accountAddLockStorage.Dispose(); - } - - /// - /// Gets the basic information about a server. - /// - /// Server Information - /// - /// Request failed on the HTTP layer (received a non-successful response code) - /// - public async Task GetServerInfo(Uri server, CancellationToken cancellationToken = default) - { - using var gqlClient = graphQLClientFactory.CreateGraphQLClient(server, null); - - //lang=graphql - const string QUERY_STRING = "query { serverInfo { name company migration { movedFrom movedTo } } }"; - - var request = new GraphQLRequest { Query = QUERY_STRING }; - - var response = await gqlClient.SendQueryAsync(request, cancellationToken).ConfigureAwait(false); - - response.EnsureGraphQLSuccess(); - - ServerInfo serverInfo = response.Data.serverInfo; - serverInfo.url = server.ToString().TrimEnd('/'); - - return response.Data.serverInfo; - } - - /// - /// Gets basic user information given a token and a server. - /// - /// - /// Server URL - /// - /// Request failed on the HTTP layer (received a non-successful response code) - /// - public async Task GetUserInfo(string token, Uri server, CancellationToken cancellationToken = default) - { - using var gqlClient = graphQLClientFactory.CreateGraphQLClient(server, token); - - //language=graphql - const string QUERY = """ - query { - data:activeUser { - name - email - id - company - } - } - """; - var request = new GraphQLRequest { Query = QUERY }; - - var response = await gqlClient - .SendQueryAsync>(request, cancellationToken) - .ConfigureAwait(false); - - response.EnsureGraphQLSuccess(); - - return response.Data.data; - } - - /// - /// The Default Server URL for authentication, can be overridden by placing a file with the alternatrive url in the Speckle folder or with an ENV_VAR - /// - public Uri GetDefaultServerUrl() - { - var customServerUrl = ""; - - // first mechanism, check for local file - var customServerFile = Path.Combine(SpecklePathProvider.UserSpeckleFolderPath, "server"); - if (File.Exists(customServerFile)) - { - customServerUrl = File.ReadAllText(customServerFile); - } - - // second mechanism, check ENV VAR - var customServerEnvVar = Environment.GetEnvironmentVariable("SPECKLE_SERVER"); - if (!string.IsNullOrEmpty(customServerEnvVar)) - { - customServerUrl = customServerEnvVar; - } - - if (!string.IsNullOrEmpty(customServerUrl)) - { - if (Uri.TryCreate(customServerUrl, UriKind.Absolute, out Uri? url)) - { - return url; - } - } - - return new Uri(DEFAULT_SERVER_URL); } /// The Id of the account to fetch @@ -151,37 +41,6 @@ public sealed class AccountManager( ?? throw new SpeckleAccountManagerException($"Account {id} not found"); } - /// - /// Upgrades an account from the account.serverInfo.movedFrom account to the account.serverInfo.movedTo account - /// - /// Id of the account to upgrade - public void UpgradeAccount(string id) - { - Account account = GetAccount(id); - - if (account.serverInfo.migration?.movedTo is not Uri upgradeUri) - { - throw new SpeckleAccountManagerException( - $"Server with url {account.serverInfo.url} does not have information about the upgraded server" - ); - } - - account.serverInfo.migration.movedTo = null; - account.serverInfo.migration.movedFrom = new Uri(account.serverInfo.url); - account.serverInfo.url = upgradeUri.ToString().TrimEnd('/'); - - // setting the id to null will force it to be recreated - account.id = null!; //TODO this is gross so remove when id is nullable - - RemoveAccount(id); - _accountStorage.UpdateObject(account.id.NotNull(), JsonConvert.SerializeObject(account)); - } - - public IEnumerable GetAccounts(string serverUrl) - { - return GetAccounts(new Uri(serverUrl)); - } - /// /// Returns all unique accounts matching the serverUrl provided. If an account exists on more than one server, /// typically because it has been migrated, then only the upgraded account (and therefore server) are returned. @@ -245,7 +104,6 @@ public sealed class AccountManager( static bool IsInvalid(Account ac) => ac.userInfo == null || ac.serverInfo == null; var sqlAccounts = _accountStorage.GetAllObjects().Select(x => JsonConvert.DeserializeObject(x.Json)); - var localAccounts = GetLocalAccounts(); foreach (var acc in sqlAccounts) { @@ -259,119 +117,88 @@ public sealed class AccountManager( yield return acc; } } - - foreach (var acc in localAccounts) - { - yield return acc; - } } /// - /// Gets the local accounts - /// These are accounts not handled by Manager and are stored in json format in a local directory + /// Refetches all local accounts (in local db), including and . + /// If the looks to be expired, this function will also attempt to use the to refresh it. + /// Will write the changes to the local accounts db /// - /// - private IList GetLocalAccounts() + /// + /// + /// + public async Task UpdateAccount(Account account, CancellationToken cancellationToken = default) { - var accountsDir = SpecklePathProvider.AccountsFolderPath; - if (!Directory.Exists(accountsDir)) + string oldAccountId = account.id; + await UpdateAccountInMemory(account, cancellationToken).ConfigureAwait(false); + + if (oldAccountId != account.id) { - return Array.Empty(); + // ID may have changed, e.g. users email changed, or server url migrated + _accountStorage.DeleteObject(oldAccountId); } - - var accounts = new List(); - string[] files = Directory.GetFiles(accountsDir, "*.json", SearchOption.AllDirectories); - foreach (var file in files) - { - try - { - var json = File.ReadAllText(file); - Account? account = JsonConvert.DeserializeObject(json); - - if ( - account is not null - && !string.IsNullOrEmpty(account.token) - && !string.IsNullOrEmpty(account.userInfo.id) - && !string.IsNullOrEmpty(account.userInfo.email) - && !string.IsNullOrEmpty(account.userInfo.name) - && !string.IsNullOrEmpty(account.serverInfo.url) - && !string.IsNullOrEmpty(account.serverInfo.name) - ) - { - accounts.Add(account); - } - } - catch (Exception ex) when (!ex.IsFatal()) - { - logger.LogWarning(ex, "Failed to load json account at {filePath}", file); - } - } - - return accounts; + _accountStorage.UpdateObject(account.id, JsonConvert.SerializeObject(account)); } /// - /// Refetches user and server info for each account + /// Refetches the information, including and + /// If the looks to be expired, this function will also attempt to use the to refresh it. + /// + /// Will only mutate in memory only, and only if successful. /// - /// It is defaultAppId in the server. By default it is "sca" to not break existing parts that this function involves. - /// - public async Task UpdateAccounts(CancellationToken ct = default, string app = "sca") + /// + /// + /// + /// Thrown if + public async Task UpdateAccountInMemory(Account account, CancellationToken cancellationToken = default) { - // need to ToList() the GetAccounts call or the UpdateObject call at the end of this method - // will not work because sqlite does not support concurrent db calls - foreach (var account in GetAccounts().ToList()) + Uri url = account.serverInfo.migration?.movedTo ?? new(account.serverInfo.url); + ActiveUserServerInfoResponse userServerInfo; + + try { + userServerInfo = await accountFactory + .GetUserServerInfo(url, account.token, cancellationToken) + .ConfigureAwait(false); + } + catch (GraphQLHttpRequestException ex) + { + // Failed to fetch info, perhaps the token is expired? + // Attempt to refresh it + TokenExchangeResponse refreshTokenResponse; try { - Uri url = new(account.serverInfo.url); - var userServerInfo = await accountFactory.GetUserServerInfo(url, account.token, ct).ConfigureAwait(false); + refreshTokenResponse = await authFlow + .GetRefreshedToken( + account.refreshToken.NotNull("No refresh token provided"), + url, + AuthApp.ConnectorsV3, + cancellationToken + ) + .ConfigureAwait(false); - //the token has expired - //TODO: once we get a token expired exception from the server use that instead - if (userServerInfo.activeUser == null || userServerInfo.serverInfo == null) - { - // We were initially was handling refresh token here bc quite a while ago server was returning null - // for activeUser and serverInfo instead of throwing exception. In short, our logic moved into catch block to cover both. - throw new SpeckleException("Token is expired"); - } - - account.isOnline = true; - account.userInfo = userServerInfo.activeUser; - account.serverInfo = userServerInfo.serverInfo; + userServerInfo = await accountFactory + .GetUserServerInfo(url, refreshTokenResponse.token, cancellationToken) + .ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch (Exception ex) when (!ex.IsFatal()) + catch (Exception ex2) { - await RefreshAndSetAccountToken(account, app).ConfigureAwait(false); + throw new AggregateException("Failed to update account information", ex, ex2); } - ct.ThrowIfCancellationRequested(); - _accountStorage.UpdateObject(account.id, JsonConvert.SerializeObject(account)); - } - } - - /// - /// Mutates the account with new tokens. - /// - /// - /// - private async Task RefreshAndSetAccountToken(Account account, string app) - { - try - { - Uri url = new(account.serverInfo.url); - var tokenResponse = await GetRefreshedToken(account.refreshToken, url, app).ConfigureAwait(false); - account.token = tokenResponse.token; - account.refreshToken = tokenResponse.refreshToken; - account.isOnline = true; - } - catch (Exception ex) when (!ex.IsFatal()) - { - account.isOnline = false; + account.token = refreshTokenResponse.token; + account.refreshToken = refreshTokenResponse.refreshToken; + logger.LogInformation(ex, "Account token has been refreshed"); } + account.userInfo = userServerInfo.activeUser.NotNull(); + account.serverInfo = userServerInfo.serverInfo; + //This is a bit gross, since id is not marked nullable + //but this will force re-generate the id (e.g. if the user's email, or servers url has changed) + account.id = null!; } /// @@ -412,325 +239,103 @@ public sealed class AccountManager( } /// - /// Retrieves the local identifier for the specified account. + /// Adds an account to local storage by prompting the user to log in via their browser. /// - /// The account for which to retrieve the local identifier. - /// The local identifier for the specified account in the form of "SERVER_URL?u=USER_ID". - /// - /// - /// - [Obsolete(Account.LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)] - public Uri? GetLocalIdentifierForAccount(Account account) - { - var identifier = account.GetLocalIdentifier(); - - // Validate account is stored locally - var searchResult = GetAccountForLocalIdentifier(identifier); - - return searchResult == null ? null : identifier; - } - - public async Task Validate(Account account) - { - Uri server = new(account.serverInfo.url); - return await GetUserInfo(account.token, server).ConfigureAwait(false); - } - - /// - /// Gets the account that corresponds to the given local identifier. - /// - /// The local identifier of the account. - /// The account that matches the local identifier, or null if no match is found. - [Obsolete(Account.LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)] - public Account? GetAccountForLocalIdentifier(Uri localIdentifier) - { - var searchResult = GetAccounts() - .FirstOrDefault(acc => - { - var id = acc.GetLocalIdentifier(); - return id == localIdentifier; - }); - - return searchResult; - } - - private Uri EnsureCorrectServerUrl(Uri? server) - { - var localUrl = server; - if (localUrl == null) - { - localUrl = GetDefaultServerUrl(); - logger.LogDebug("The provided server url was null or empty. Changed to the default url {serverUrl}", localUrl); - } - return localUrl; - } - - private void EnsureGetAccessCodeFlowIsSupported() - { - if (!HttpListener.IsSupported) - { - logger.LogError("HttpListener not supported"); - throw new PlatformNotSupportedException("Your operating system is not supported"); - } - } - - private async Task GetAccessCode(Uri server, string challenge, TimeSpan timeout) - { - EnsureGetAccessCodeFlowIsSupported(); - - logger.LogDebug("Starting auth process for {server}/authn/verify/sca/{challenge}", server, challenge); - - var accessCode = ""; - - Process.Start(new ProcessStartInfo($"{server}/authn/verify/sca/{challenge}") { UseShellExecute = true }); - - var task = Task.Run(() => - { - using var listener = new HttpListener(); - var localUrl = "http://localhost:29363/"; - listener.Prefixes.Add(localUrl); - listener.Start(); - logger.LogDebug("Listening for auth redirects on {localUrl}", localUrl); - // Note: The GetContext method blocks while waiting for a request. - HttpListenerContext context = listener.GetContext(); - HttpListenerRequest request = context.Request; - HttpListenerResponse response = context.Response; - - accessCode = request.QueryString["access_code"]; - logger.LogDebug("Got access code {accessCode}", accessCode); - - string message = - accessCode != null - ? "Success!

You can close this window now." - : "Oups, something went wrong...!"; - - var responseString = - $"
{message}"; - byte[] buffer = Encoding.UTF8.GetBytes(responseString); - response.ContentLength64 = buffer.Length; - Stream output = response.OutputStream; - output.Write(buffer, 0, buffer.Length); - output.Close(); - logger.LogDebug("Processed finished processing the access code"); - listener.Stop(); - listener.Close(); - }); - - var completedTask = await Task.WhenAny(task, Task.Delay(timeout)).ConfigureAwait(false); - - // this is means the task timed out - if (completedTask != task) - { - logger.LogWarning( - "Local auth flow failed to complete within the timeout window. Access code is {accessCode}", - accessCode - ); - throw new AuthFlowException("Local auth flow failed to complete within the timeout window"); - } - - if (task.IsFaulted && task.Exception is not null) - { - logger.LogError( - task.Exception, - "Getting access code flow failed with {exceptionMessage}", - task.Exception.Message - ); - throw new AuthFlowException($"Auth flow failed: {task.Exception.Message}", task.Exception); - } - - // task completed within timeout - logger.LogInformation( - "Local auth flow completed successfully within the timeout window. Access code is {accessCode}", - accessCode - ); - return accessCode; - } - - private async Task CreateAccount(string accessCode, string challenge, Uri server) - { - try - { - var tokenResponse = await GetToken(accessCode, challenge, server).ConfigureAwait(false); - - var account = await accountFactory - .CreateAccount(server, tokenResponse.token, tokenResponse.refreshToken) - .ConfigureAwait(false); - - account.isDefault = !GetAccounts().Any(); - - logger.LogInformation("Successfully created account for {serverUrl}", server); - - return account; - } - catch (Exception ex) when (!ex.IsFatal()) - { - throw new SpeckleAccountManagerException("Failed to create account from access code and challenge", ex); - } - } - - private void TryLockAccountAddFlow(TimeSpan timespan) - { - // use a static variable to quickly - // prevent launching this flow multiple times - if (s_isAddingAccount) - { - // this should probably throw with an error message - throw new SpeckleAccountFlowLockedException("The account add flow is already launched."); - } - - // this uses the SQLite transport to store locks - var lockIds = _accountAddLockStorage.GetAllObjects().Select(x => x.Id).OrderByDescending(d => d).ToList(); - var now = DateTime.Now; - foreach (var l in lockIds) - { - var lockArray = l.Split('@'); - var lockName = lockArray.Length == 2 ? lockArray[0] : "the other app"; - var lockTime = - lockArray.Length == 2 - ? DateTime.ParseExact(lockArray[1], "o", null) - : DateTime.ParseExact(lockArray[0], "o", null); - - if (lockTime > now) - { - var lockString = string.Format("{0:mm} minutes {0:ss} seconds", lockTime - now); - throw new SpeckleAccountFlowLockedException( - $"The account add flow was already started in {lockName}, retry in {lockString}" - ); - } - } - - var lockId = application.ApplicationAndVersion + "@" + DateTime.Now.Add(timespan).ToString("o"); - - // using the lock release time as an id and value - // for ease of deletion and retrieval - _accountAddLockStorage.SaveObject(lockId, lockId); - s_isAddingAccount = true; - } - - private void UnlockAccountAddFlow() - { - s_isAddingAccount = false; - // make sure all old locks are removed - foreach (var (id, _) in _accountAddLockStorage.GetAllObjects()) - { - _accountAddLockStorage.DeleteObject(id); - } - } - - /// - /// Adds an account by propting the user to log in via a web flow - /// - /// Server to use to add the account, if not provied the default Server will be used + /// + /// + /// Account account = await AuthenticateAccount(new Uri("https://app.speckle.systems"), TimeSpan.FromMinutes(1)); + /// + /// + /// + /// Timeout for user to auth with browser, recommend 1 min timeout + /// /// - public async Task AddAccount(Uri? server = null) + public async Task AuthenticateAccount(Uri serverUrl, TimeSpan timeout, CancellationToken cancellationToken) { - logger.LogDebug("Starting to add account for {serverUrl}", server); + logger.LogDebug("Starting to add account for {ServerUrl}", serverUrl); - server = EnsureCorrectServerUrl(server); + TokenExchangeResponse tokenResponse = await authFlow + .TriggerAuthFlowWithTimeout(serverUrl, AuthApp.ConnectorsV3, timeout, cancellationToken) + .ConfigureAwait(false); - // locking for 1 minute - var timeout = TimeSpan.FromMinutes(1); - // this is not part of the try finally block - // we do not want to clean up the existing locks - TryLockAccountAddFlow(timeout); - var challenge = GenerateChallenge(); + return await CreateAndAddAccount(serverUrl, tokenResponse, cancellationToken).ConfigureAwait(false); + } - try + public async Task CreateAndAddAccount( + Uri serverUrl, + TokenExchangeResponse tokenResponse, + CancellationToken cancellationToken + ) + { + var account = await accountFactory + .CreateAccount(serverUrl, tokenResponse.token, tokenResponse.refreshToken, cancellationToken) + .ConfigureAwait(false); + account.isDefault = !GetAccounts().Any(); + + _accountStorage.SaveObject(account.id, JsonConvert.SerializeObject(account)); + logger.LogInformation("Successfully authenticated account {AccountId} for {ServerUrl}", account.id, serverUrl); + return account; + } + + /// + /// The Default Server URL for authentication, can be overridden by placing a file with the alternative url in the Speckle folder or with an ENV_VAR + /// + [Obsolete("Unused")] + public Uri GetDefaultServerUrl() + { + var customServerUrl = ""; + + // first mechanism, check for local file + var customServerFile = Path.Combine(SpecklePathProvider.UserSpeckleFolderPath, "server"); + if (File.Exists(customServerFile)) { - string accessCode = await GetAccessCode(server, challenge, timeout).ConfigureAwait(false); - if (string.IsNullOrEmpty(accessCode)) + customServerUrl = File.ReadAllText(customServerFile); + } + + // second mechanism, check ENV VAR + var customServerEnvVar = Environment.GetEnvironmentVariable("SPECKLE_SERVER"); + if (!string.IsNullOrEmpty(customServerEnvVar)) + { + customServerUrl = customServerEnvVar; + } + + if (!string.IsNullOrEmpty(customServerUrl)) + { + if (Uri.TryCreate(customServerUrl, UriKind.Absolute, out Uri? url)) { - throw new SpeckleAccountManagerException("Access code is invalid"); + return url; } + } - var account = await CreateAccount(accessCode, challenge, server).ConfigureAwait(false); - - //if the account already exists it will not be added again - _accountStorage.SaveObject(account.id, JsonConvert.SerializeObject(account)); - logger.LogDebug("Finished adding account {accountId} for {serverUrl}", account.id, server); - } - catch (SpeckleAccountManagerException ex) - { - logger.LogCritical(ex, "Failed to add account: {exceptionMessage}", ex.Message); - // rethrowing any known errors - throw; - } - catch (Exception ex) when (!ex.IsFatal()) - { - logger.LogCritical(ex, "Failed to add account: {exceptionMessage}", ex.Message); - throw new SpeckleAccountManagerException($"Failed to add account: {ex.Message}", ex); - } - finally - { - UnlockAccountAddFlow(); - } + return new Uri(DEFAULT_SERVER_URL); } - private async Task GetToken(string accessCode, string challenge, Uri server) + [Obsolete("Use Uri overload")] + public IEnumerable GetAccounts(string serverUrl) { - try - { - using var client = speckleHttp.CreateHttpClient(); - - var body = new - { - appId = "sca", - appSecret = "sca", - accessCode, - challenge, - }; - - using var content = new StringContent(JsonConvert.SerializeObject(body)); - content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - var response = await client.PostAsync(new Uri(server, "/auth/token"), content).ConfigureAwait(false); - - return JsonConvert - .DeserializeObject(await response.Content.ReadAsStringAsync().ConfigureAwait(false)) - .NotNull(); - } - catch (Exception ex) when (!ex.IsFatal()) - { - throw new SpeckleException($"Failed to get authentication token from {server}", ex); - } + return GetAccounts(new Uri(serverUrl)); } - private async Task GetRefreshedToken(string? refreshToken, Uri server, string app = "sca") - { - try - { - using var client = speckleHttp.CreateHttpClient(); + [Obsolete("Use UpdateAccount instead for more control over error handling", true)] + public Task UpdateAccounts(CancellationToken ct = default, string app = "sca") => throw new NotImplementedException(); - var body = new - { - appId = app, - appSecret = app, - refreshToken, - }; + [Obsolete("Use UpdateAccount instead", true)] + public void UpgradeAccount(string id) => throw new NotImplementedException(); - using var content = new StringContent(JsonConvert.SerializeObject(body)); - content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - var response = await client.PostAsync(new Uri(server, "/auth/token"), content).ConfigureAwait(false); + [Obsolete($"Use {nameof(AuthenticateAccount)} instead", true)] + public Task AddAccount(Uri? server = null) => throw new NotImplementedException(); - return JsonConvert - .DeserializeObject(await response.Content.ReadAsStringAsync().ConfigureAwait(false)) - .NotNull(); - } - catch (Exception ex) when (!ex.IsFatal()) - { - throw new SpeckleException($"Failed to get refreshed token from {server}", ex); - } - } + [Obsolete("Use serverInfo stored on a client instead", true)] + public Task GetServerInfo(Uri server, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); - private static string GenerateChallenge() - { -#if NET8_0 - byte[] challengeData = RandomNumberGenerator.GetBytes(32); -#else - using RNGCryptoServiceProvider rng = new(); - byte[] challengeData = new byte[32]; - rng.GetBytes(challengeData); -#endif - //escaped chars like % do not play nice with the server - return Regex.Replace(Convert.ToBase64String(challengeData), @"[^\w\.@-]", ""); - } + [Obsolete("Use userInfo stored on a client instead", true)] + public Task GetUserInfo(string token, Uri server, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + [Obsolete("Accounts must now be stored in sqlite db, no more json workaround", true)] + public IList GetLocalAccounts() => throw new NotImplementedException(); + + [Obsolete("Use UpdateAccount or UpdateAccountInMemory Instead", true)] + public IList Validate() => throw new NotImplementedException(); } diff --git a/src/Speckle.Sdk/Credentials/AuthApp.cs b/src/Speckle.Sdk/Credentials/AuthApp.cs new file mode 100644 index 00000000..00478414 --- /dev/null +++ b/src/Speckle.Sdk/Credentials/AuthApp.cs @@ -0,0 +1,13 @@ +namespace Speckle.Sdk.Credentials; + +public readonly record struct AuthApp(string AppId, string AppSecret, Uri CallbackUrl) +{ + //These values are defined on the server, and specify the scopes the app is requesting + public static AuthApp ConnectorsV3 { get; } = + new() + { + AppId = "connectrV3", + AppSecret = "connectrV3", + CallbackUrl = new Uri("http://localhost:29355"), + }; +} diff --git a/src/Speckle.Sdk/Credentials/AuthFlow.cs b/src/Speckle.Sdk/Credentials/AuthFlow.cs new file mode 100644 index 00000000..f849da97 --- /dev/null +++ b/src/Speckle.Sdk/Credentials/AuthFlow.cs @@ -0,0 +1,327 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using Speckle.InterfaceGenerator; +using Speckle.Newtonsoft.Json; +using Speckle.Sdk.Common; +using Speckle.Sdk.Helpers; +using Speckle.Sdk.Logging; + +namespace Speckle.Sdk.Credentials; + +/// +/// Authentication flow with the Speckle Server to create a application token for the connectorsV3 application +/// Starts the browser based authentication flow where the user's browser will be opened, they'll be asked to +/// confirm permission, then an access code will be given via a which will be exchanged +/// for a +/// +/// +/// Note, this class is not coupled in any way to +/// lets keep it that way... +/// See instead +/// +[GenerateAutoInterface] +public sealed class AuthFlow(ISdkActivityFactory activityFactory, ISpeckleHttp speckleHttp) : IAuthFlow +{ + private readonly JsonSerializerSettings _serializerSettings = new() + { + MissingMemberHandling = MissingMemberHandling.Error, + NullValueHandling = NullValueHandling.Ignore, + }; + + public async Task TriggerAuthFlowWithTimeout( + Uri serverUrl, + AuthApp authApp, + TimeSpan timeout, + CancellationToken cancellationToken + ) + { + using HttpClient client = speckleHttp.CreateHttpClient(); + + Uri tokenEndpoint = new(serverUrl, "/oauth/token"); + string codeVerifier = GenerateCodeVerifier(); + string challenge; + string codeChallengeMethod; + var req = await client.GetAsync(tokenEndpoint, cancellationToken).ConfigureAwait(false); + bool useLegacyEndpoint = req.StatusCode != HttpStatusCode.OK; + + if (useLegacyEndpoint) + { + challenge = codeVerifier; + tokenEndpoint = new(serverUrl, "/auth/token"); + codeChallengeMethod = ""; + } + else + { + challenge = GenerateCodeChallenge(codeVerifier); + codeChallengeMethod = "?code_challenge_method=S256"; + } + + Uri endpoint = new(serverUrl, $"/authn/verify/{authApp.AppId}/{challenge}{codeChallengeMethod}"); + _ = Process.Start(new ProcessStartInfo(endpoint.ToString()) { UseShellExecute = true }); + string accessCode = await RunListenerWithTimeout(authApp.CallbackUrl, timeout, cancellationToken) + .ConfigureAwait(false); + + return await ExchangeAccessCodeForToken( + client, + accessCode, + authApp, + useLegacyEndpoint ? challenge : null, + !useLegacyEndpoint ? codeVerifier : null, + tokenEndpoint, + cancellationToken + ) + .ConfigureAwait(false); + } + + /// + /// + /// + /// + /// + /// + /// + /// requested cancel + /// timeout was reached + public async Task RunListenerWithTimeout( + Uri applicationCallbackUrl, + TimeSpan timeout, + CancellationToken userCancellation + ) + { + using CancellationTokenSource cancelOnTimeout = new(timeout); + using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource( + cancelOnTimeout.Token, + userCancellation + ); + + try + { + using var activity = activityFactory.Start("Listening for authflow access code"); + + return await RunListener(applicationCallbackUrl, linkedSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (userCancellation.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException ex) when (cancelOnTimeout.IsCancellationRequested) + { + throw new TimeoutException($"Auth flow was cancelled after {timeout:g} timeout", ex); + } + } + + /// + /// + /// + /// + /// + /// Auth app, needs to match the app that generated the refresh token originally + /// + /// HTTP exceptions + /// Server response was invalid or partial + /// Invalid (must be absolute url) + /// requested cancel + /// + public async Task GetRefreshedToken( + string? refreshToken, + Uri serverUrl, + AuthApp authApp, + CancellationToken cancellationToken + ) + { + using var client = speckleHttp.CreateHttpClient(); + + var body = new + { + appId = authApp.AppId, + appSecret = authApp.AppSecret, + refreshToken = refreshToken, + }; + + using var content = new StringContent(JsonConvert.SerializeObject(body)); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + var response = await client + .PostAsync(new Uri(serverUrl, "/auth/token"), content, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + +#if NET8_0_OR_GREATER + string read = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string read = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + return JsonConvert.DeserializeObject(read, _serializerSettings).NotNull(); + } + + private static async Task GetContext(HttpListener listener, CancellationToken cancellationToken) + { + //GetContextAsync doesn't support cancellation, so we have to do this song and dance... + Task timeoutTask = Task.Delay(Timeout.Infinite, cancellationToken); + Task getContextTask = listener.GetContextAsync(); + + Task completed = await Task.WhenAny(getContextTask, timeoutTask).ConfigureAwait(false); + if (completed == getContextTask) + { + return getContextTask.Result; + } + + cancellationToken.ThrowIfCancellationRequested(); + + throw new InvalidOperationException("Cancellation should have thrown, this shouldn't be possible"); + } + + public static async Task RunListener(Uri localUrl, CancellationToken cancellationToken) + { + using HttpListener listener = new(); + listener.Prefixes.Add(localUrl.ToString()); + listener.Start(); + + HttpListenerContext context = await GetContext(listener, cancellationToken).ConfigureAwait(false); + HttpListenerRequest request = context.Request; + using HttpListenerResponse response = context.Response; + + string? accessCode = request.QueryString["access_code"]; + string? denied = request.QueryString["denied"]; + bool isDenied = denied == "true"; + + if (isDenied) + { + //lang=html + WriteResponse( + """ +

Denied!

+

+ Please close this window and return to your Speckle Connector. + """ + ); + throw new AuthFlowException("Authentication flow was denied"); //denied presumably by the user + } + else if (accessCode != null) + { + //lang=html + WriteResponse( + """ +

Success!

+

+ Your Speckle Connector is now authorized +

+ You may now close this window and return to your Speckle Connector + """ + ); + return accessCode; + } + else + { + //lang=html + WriteResponse( + """ +

Failed!

+

+ Something went wrong trying to authorize your Speckle Connector +

+ Please close this window and try again from your Speckle Connector. + """ + ); + throw new AuthFlowException("Failed to receive access code"); + } + + void WriteResponse(string message) + { + //lang=html + string responseString = $""" + + +
+ {message} + + + """; + + byte[] buffer = Encoding.UTF8.GetBytes(responseString); + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + } + } + + private async Task ExchangeAccessCodeForToken( + HttpClient client, + string accessCode, + AuthApp authApp, + string? challenge, + string? codeVerifier, + Uri tokenEndpoint, + CancellationToken cancellationToken + ) + { + var body = new + { + appId = authApp.AppId, + appSecret = authApp.AppSecret, + accessCode = accessCode, + challenge = challenge, + codeVerifier = codeVerifier, + }; + + using StringContent content = new(JsonConvert.SerializeObject(body, _serializerSettings)); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + using HttpResponseMessage response = await client + .PostAsync(tokenEndpoint, content, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + +#if NET8_0_OR_GREATER + string read = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string read = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + + return JsonConvert.DeserializeObject(read, _serializerSettings).NotNull(); + } + + public static string GenerateCodeVerifier() + { +#if NET8_0_OR_GREATER + Span codeVerifierData = stackalloc byte[32]; + RandomNumberGenerator.Fill(codeVerifierData); +#else + using RNGCryptoServiceProvider rng = new(); + byte[] codeVerifierData = new byte[32]; + rng.GetBytes(codeVerifierData); +#endif + + return Base64UrlEncode(codeVerifierData); + } + + public static string GenerateCodeChallenge(string codeVerifier) + { +#if NET8_0_OR_GREATER + int byteCount = Encoding.UTF8.GetByteCount(codeVerifier.AsSpan()); + Span codeVerifierBytes = stackalloc byte[byteCount]; + Encoding.UTF8.GetBytes(codeVerifier, codeVerifierBytes); + Span challengeData = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(codeVerifierBytes, challengeData); +#else + byte[] codeVerifierBytes = Encoding.UTF8.GetBytes(codeVerifier); + using SHA256 hash = SHA256.Create(); + byte[] challengeData = hash.ComputeHash(codeVerifierBytes); +#endif + return Base64UrlEncode(challengeData); + } + + private static string Base64UrlEncode( +#if NET8_0_OR_GREATER + ReadOnlySpan bytes +#else + byte[] bytes +#endif + ) + { + // Base64Url is available in .NET 9, or via the Microsoft.Bcl.Memory polyfill + // But for simplicity r.e. dll dependencies, we're doing it the dumb way... + return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); + } +} diff --git a/src/Speckle.Sdk/Credentials/AuthFlowException.cs b/src/Speckle.Sdk/Credentials/AuthFlowException.cs deleted file mode 100644 index cb720d00..00000000 --- a/src/Speckle.Sdk/Credentials/AuthFlowException.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Speckle.Sdk.Credentials; - -#pragma warning disable CA2237 -public sealed class AuthFlowException : Exception -#pragma warning restore CA2237 -{ - public AuthFlowException(string? message, Exception? innerException) - : base(message, innerException) { } - - public AuthFlowException(string? message) - : base(message) { } - - public AuthFlowException() { } -} diff --git a/src/Speckle.Sdk/Credentials/Exceptions.cs b/src/Speckle.Sdk/Credentials/Exceptions.cs index e33738a0..9c85c017 100644 --- a/src/Speckle.Sdk/Credentials/Exceptions.cs +++ b/src/Speckle.Sdk/Credentials/Exceptions.cs @@ -1,5 +1,16 @@ namespace Speckle.Sdk.Credentials; +public sealed class AuthFlowException : SpeckleException +{ + public AuthFlowException(string? message, Exception? innerException) + : base(message, innerException) { } + + public AuthFlowException(string? message) + : base(message) { } + + public AuthFlowException() { } +} + public class SpeckleAccountManagerException : SpeckleException { public SpeckleAccountManagerException(string message) @@ -10,14 +21,3 @@ public class SpeckleAccountManagerException : SpeckleException public SpeckleAccountManagerException() { } } - -public class SpeckleAccountFlowLockedException : SpeckleAccountManagerException -{ - public SpeckleAccountFlowLockedException(string message) - : base(message) { } - - public SpeckleAccountFlowLockedException() { } - - public SpeckleAccountFlowLockedException(string message, Exception? innerException) - : base(message, innerException) { } -} diff --git a/src/Speckle.Sdk/Credentials/Responses.cs b/src/Speckle.Sdk/Credentials/Responses.cs index 24f12117..996bb520 100644 --- a/src/Speckle.Sdk/Credentials/Responses.cs +++ b/src/Speckle.Sdk/Credentials/Responses.cs @@ -6,16 +6,19 @@ namespace Speckle.Sdk.Credentials; internal sealed class ActiveUserServerInfoResponse { [property: JsonProperty(Required = Required.AllowNull)] - public UserInfo? activeUser { get; init; } + public required UserInfo? activeUser { get; init; } [property: JsonProperty(Required = Required.Always)] - public ServerInfo serverInfo { get; init; } + public required ServerInfo serverInfo { get; init; } } -internal sealed class TokenExchangeResponse +public sealed class TokenExchangeResponse { - public string token { get; init; } - public string refreshToken { get; init; } + [JsonRequired] + public required string token { get; init; } + + [JsonRequired] + public required string refreshToken { get; init; } } public sealed class UserInfo diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/WorkspaceResourceTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/WorkspaceResourceTests.cs index d597472d..3f4ed00c 100644 --- a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/WorkspaceResourceTests.cs +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/WorkspaceResourceTests.cs @@ -1,4 +1,5 @@ -using Speckle.Sdk.Api; +using FluentAssertions; +using Speckle.Sdk.Api; using Speckle.Sdk.Api.GraphQL.Resources; namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources; @@ -25,15 +26,15 @@ public class WorkspaceResourceTests public async Task TestGetWorkspace() { var ex = await Assert.ThrowsAsync(async () => _ = await Sut.Get("non-existent-id")); - Assert.Single(ex.InnerExceptions); - Assert.All(ex.InnerExceptions, item => Assert.IsType(item)); + ex.InnerExceptions.Should().HaveCount(1); + ex.InnerExceptions.Should().AllBeOfType(); } [Fact] public async Task TestGetProjects() { var ex = await Assert.ThrowsAsync(async () => _ = await Sut.GetProjects("non-existent-id")); - Assert.Single(ex.InnerExceptions); - Assert.All(ex.InnerExceptions, item => Assert.IsType(item)); + ex.InnerExceptions.Should().HaveCount(1); + ex.InnerExceptions.Should().AllBeOfType(); } } diff --git a/tests/Speckle.Sdk.Tests.Integration/Collections.cs b/tests/Speckle.Sdk.Tests.Integration/Collections.cs new file mode 100644 index 00000000..d5a850db --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Integration/Collections.cs @@ -0,0 +1,7 @@ +namespace Speckle.Sdk.Tests.Integration; + +[CollectionDefinition(nameof(RequiresSqLiteAccountDb), DisableParallelization = true)] +public sealed class RequiresSqLiteAccountDb; + +[CollectionDefinition(nameof(RequiresAuthFlowPort), DisableParallelization = true)] +public sealed class RequiresAuthFlowPort; diff --git a/tests/Speckle.Sdk.Tests.Integration/Credentials/AccountManagerTests.cs b/tests/Speckle.Sdk.Tests.Integration/Credentials/AccountManagerTests.cs new file mode 100644 index 00000000..c044d452 --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Integration/Credentials/AccountManagerTests.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.DependencyInjection; +using Speckle.Sdk.Api; +using Speckle.Sdk.Api.GraphQL.Models; +using Speckle.Sdk.Credentials; + +namespace Speckle.Sdk.Tests.Integration.Credentials; + +[Collection(nameof(RequiresSqLiteAccountDb))] +public class AccountManagerTests +{ + private IAccountManager _sut; + + public AccountManagerTests() + { + _sut = Fixtures.ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task UpdateAccount_UpdatesUserInfo() + { + using IClient user = await Fixtures.SeedUserWithClient(); + string realAccountId = user.Account.id; + UserInfo realUserData = user.Account.userInfo; + UserInfo staleData = new() + { + avatar = "my old avatar", + company = "my old company", + email = "my.old.email@example.com", + id = realUserData.id, + name = "my old name", + }; + // Mutate with "fake" data to simulate a stale account data + user.Account.userInfo = staleData; + user.Account.id = null!; //force re-generate id + + Assert.NotEqual(realAccountId, user.Account.id); + + await _sut.UpdateAccountInMemory(user.Account); + + Assert.Equal(realUserData.avatar, user.Account.userInfo.avatar); + Assert.Equal(realUserData.company, user.Account.userInfo.company); + Assert.Equal(realUserData.email, user.Account.userInfo.email); + Assert.Equal(realUserData.id, user.Account.userInfo.id); + Assert.Equal(realUserData.name, user.Account.userInfo.name); + Assert.Equal(realAccountId, user.Account.id); + } + + [Fact] + public async Task UpdateAccount_UpdatesServerInfo() + { + using IClient user = await Fixtures.SeedUserWithClient(); + string realAccountId = user.Account.id; + ServerInfo realServerData = user.Account.serverInfo; + ServerInfo staleData = new() + { + company = "This old company", + description = "this old description", + name = "This old name", + url = realServerData.url, + version = "0.0.123", + }; + // Mutate with "fake" data to simulate a stale account data + user.Account.serverInfo = staleData; + user.Account.id = null!; //force re-generate id + + Assert.Equal(realAccountId, user.Account.id); //account id should not change since we didn't change server url + + await _sut.UpdateAccountInMemory(user.Account); + + Assert.Equal(realServerData.company, user.Account.serverInfo.company); + Assert.Equal(realServerData.description, user.Account.serverInfo.description); + Assert.Equal(realServerData.name, user.Account.serverInfo.name); + Assert.Equal(realServerData.url, user.Account.serverInfo.url); + Assert.Equal(realServerData.version, user.Account.serverInfo.version); + Assert.Equal(realAccountId, user.Account.id); + } + + [Fact] + public async Task UpdateAccount_ServerInfoMigration() + { + using IClient user = await Fixtures.SeedUserWithClient(); + string realAccountId = user.Account.id; + ServerInfo realServerData = user.Account.serverInfo; + ServerInfo staleData = new() + { + company = "This old company", + description = "this old description", + name = "This old name", + url = realServerData.url, + version = "0.0.123", + }; + // Mutate with "fake" data to simulate a stale account data + user.Account.serverInfo = staleData; + user.Account.id = null!; //force re-generate id + + Assert.Equal(realAccountId, user.Account.id); //account id should not change since we didn't change server url + + await _sut.UpdateAccountInMemory(user.Account); + + Assert.Equal(realServerData.company, user.Account.serverInfo.company); + Assert.Equal(realServerData.description, user.Account.serverInfo.description); + Assert.Equal(realServerData.name, user.Account.serverInfo.name); + Assert.Equal(realServerData.url, user.Account.serverInfo.url); + Assert.Equal(realServerData.version, user.Account.serverInfo.version); + Assert.Equal(realAccountId, user.Account.id); + } +} diff --git a/tests/Speckle.Sdk.Tests.Integration/Credentials/AuthFlowExceptionalTests.cs b/tests/Speckle.Sdk.Tests.Integration/Credentials/AuthFlowExceptionalTests.cs new file mode 100644 index 00000000..32a0afd3 --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Integration/Credentials/AuthFlowExceptionalTests.cs @@ -0,0 +1,84 @@ +using System.Net; +using Microsoft.Extensions.DependencyInjection; +using Speckle.Sdk.Api; +using Speckle.Sdk.Credentials; + +namespace Speckle.Sdk.Tests.Integration.Credentials; + +[Collection(nameof(RequiresAuthFlowPort))] +public class AuthFlowExceptionalTests : IAsyncLifetime +{ + private IAuthFlow _authFlow; + private IClient _client; + private readonly Uri _url = AuthApp.ConnectorsV3.CallbackUrl; + + [Fact] + public async Task GetRefreshToken_Cancellation() + { + await Assert.ThrowsAnyAsync(async () => + _ = await _authFlow.GetRefreshedToken( + _client.Account.refreshToken, + _client.ServerUrl, + Fixtures.TestAuthApp, + new(true) + ) + ); + } + + [Fact] + public async Task GetRefreshToken_UnknownApp() + { + //interestingly, the server responds with a 401 Unauthorized despite internally being a bad request + await Assert.ThrowsAnyAsync(async () => + _ = await _authFlow.GetRefreshedToken( + _client.Account.refreshToken, + _client.ServerUrl, + new() + { + AppId = "doesn't exist", + AppSecret = "doesn't exist", + CallbackUrl = new("invalid://localhost"), + }, + CancellationToken.None + ) + ); + } + + [Fact] + public async Task GetRefreshToken_NullRefreshToken() + { + await Assert.ThrowsAnyAsync(async () => + _ = await _authFlow.GetRefreshedToken(null, _client.ServerUrl, AuthApp.ConnectorsV3, CancellationToken.None) + ); + } + + [Fact] + public async Task SimultaneousListeners_SamePort_OneFails() + { + using CancellationTokenSource ct = new(); + var task1 = AuthFlow.RunListener(_url, ct.Token); + await Task.Delay(50, CancellationToken.None); + + await Assert.ThrowsAsync(async () => await AuthFlow.RunListener(_url, ct.Token)); + + if (task1.IsCompleted) + { + throw new InvalidOperationException("Was expecting task to still be running", task1.Exception); + } + + await ct.CancelAsync(); + await Assert.ThrowsAnyAsync(async () => await task1); + } + + public async Task InitializeAsync() + { + _authFlow = Fixtures.ServiceProvider.GetRequiredService(); + _client = await Fixtures.SeedUserWithClient(); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } +} diff --git a/tests/Speckle.Sdk.Tests.Integration/Credentials/AuthFlowTests.cs b/tests/Speckle.Sdk.Tests.Integration/Credentials/AuthFlowTests.cs new file mode 100644 index 00000000..b5719f7c --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Integration/Credentials/AuthFlowTests.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.DependencyInjection; +using Speckle.Sdk.Credentials; + +namespace Speckle.Sdk.Tests.Integration.Credentials; + +[Collection(nameof(RequiresAuthFlowPort))] +public sealed class AuthFlowTests +{ + private readonly IAuthFlow _authFlow; + private readonly Uri _url = AuthApp.ConnectorsV3.CallbackUrl; + + public AuthFlowTests() + { + _authFlow = Fixtures.ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task RunListener_ReturnsAccessCode_WhenQueryContainsAccessCode() + { + var listenerTask = AuthFlow.RunListener(_url, CancellationToken.None); + using var client = new HttpClient(); + const string EXPECTED_ACCESS_CODE = "abcdef123456"; + + var response = await client.GetAsync(new Uri(_url, $"?access_code={EXPECTED_ACCESS_CODE}")); + response.EnsureSuccessStatusCode(); + + string result = await listenerTask; + + Assert.Equal(EXPECTED_ACCESS_CODE, result); + } + + [Fact] + public async Task RunListener_Throws_InvalidAccessCode() + { + var listenerTask = AuthFlow.RunListener(_url, CancellationToken.None); + using var client = new HttpClient(); + + var response = await client.GetAsync(new Uri(_url, "")); + response.EnsureSuccessStatusCode(); + + await Assert.ThrowsAsync(async () => + { + _ = await listenerTask; + }); + } + + [Fact] + public async Task RunListener_Throws_Cancellation() + { + using CancellationTokenSource cancellationTokenSource = new(); + var listenerTask = AuthFlow.RunListener(_url, cancellationTokenSource.Token); + + await cancellationTokenSource.CancelAsync(); + + await Assert.ThrowsAsync(async () => + { + _ = await listenerTask; + }); + } + + [Theory] + [InlineData(0.1)] + [InlineData(1)] + [InlineData(5)] + public async Task RunListener_Timeout(double timeS) + { + await Assert.ThrowsAsync(async () => + { + _ = await _authFlow.RunListenerWithTimeout(_url, TimeSpan.FromSeconds(timeS), CancellationToken.None); + }); + } + + [Fact] + public async Task CanGetRefreshToken() + { + using var user = await Fixtures.SeedUserWithClient(); + var tokenExchange = await _authFlow.GetRefreshedToken( + user.Account.refreshToken, + user.ServerUrl, + Fixtures.TestAuthApp, + CancellationToken.None + ); + + Assert.NotNull(tokenExchange.token); + Assert.NotNull(tokenExchange.refreshToken); + + user.Account.token = tokenExchange.token; + user.Account.refreshToken = tokenExchange.refreshToken; + + var apiTest = await user.ActiveUser.Get(); + + Assert.NotNull(apiTest); + } +} diff --git a/tests/Speckle.Sdk.Tests.Integration/Fixtures.cs b/tests/Speckle.Sdk.Tests.Integration/Fixtures.cs index 95da56e5..8cab800b 100644 --- a/tests/Speckle.Sdk.Tests.Integration/Fixtures.cs +++ b/tests/Speckle.Sdk.Tests.Integration/Fixtures.cs @@ -27,7 +27,12 @@ namespace Speckle.Sdk.Tests.Integration; public static class Fixtures { public static readonly ServerInfo Server = new() { url = "http://localhost:3000", name = "Docker Server" }; - + public static readonly AuthApp TestAuthApp = new() + { + AppId = "spklwebapp", + AppSecret = "spklwebapp", + CallbackUrl = new Uri("invaid://localhost"), + }; public static IServiceProvider ServiceProvider { get; set; } static Fixtures() @@ -95,8 +100,8 @@ public static class Fixtures Dictionary tokenBody = new() { ["accessCode"] = accessCode, - ["appId"] = "spklwebapp", - ["appSecret"] = "spklwebapp", + ["appId"] = TestAuthApp.AppId, + ["appSecret"] = TestAuthApp.AppSecret, ["challenge"] = "challengingchallenge", }; @@ -109,8 +114,11 @@ public static class Fixtures ); var token = deserialised.NotNull()["token"].NotNull(); + var refreshToken = deserialised.NotNull()["refreshToken"].NotNull(); - return await ServiceProvider.GetRequiredService().CreateAccount(new(Server.url), token); + return await ServiceProvider + .GetRequiredService() + .CreateAccount(new(Server.url), token, refreshToken); } public static Base GenerateSimpleObject() diff --git a/tests/Speckle.Sdk.Tests.Unit/Credentials/AccountManagerTests.cs b/tests/Speckle.Sdk.Tests.Unit/Credentials/AccountManagerTests.cs index 91594975..3e2f5bff 100644 --- a/tests/Speckle.Sdk.Tests.Unit/Credentials/AccountManagerTests.cs +++ b/tests/Speckle.Sdk.Tests.Unit/Credentials/AccountManagerTests.cs @@ -3,7 +3,6 @@ using Moq; using Speckle.Newtonsoft.Json; using Speckle.Sdk.Api.GraphQL.Models; using Speckle.Sdk.Credentials; -using Speckle.Sdk.Helpers; using Speckle.Sdk.SQLite; using Speckle.Sdk.Testing; @@ -28,14 +27,11 @@ public sealed class AccountManagerTests : MoqTest ) => throw new NotImplementedException(); } - private readonly Mock _mockApplication; private readonly Mock> _mockLogger; - private readonly Mock _mockGraphQLClientFactory; - private readonly Mock _mockSpeckleHttp; private readonly IAccountFactory _mockAccountFactory; private readonly Mock _mockSqLiteJsonCacheManagerFactory; private readonly Mock _mockAccountStorage; - private readonly Mock _mockAccountAddLockStorage; + private readonly Mock _mockAuthFlow; #pragma warning disable CA2213 private readonly AccountManager _accountManager; @@ -43,27 +39,19 @@ public sealed class AccountManagerTests : MoqTest public AccountManagerTests() { - _mockApplication = Create(); _mockLogger = Create>(MockBehavior.Loose); - _mockGraphQLClientFactory = Create(); - _mockSpeckleHttp = Create(); _mockAccountFactory = new TestAccountFactory(); _mockSqLiteJsonCacheManagerFactory = Create(); + _mockAuthFlow = Create(); _mockAccountStorage = Create(); - _mockAccountAddLockStorage = Create(); _mockSqLiteJsonCacheManagerFactory.Setup(f => f.CreateForUser("Accounts")).Returns(_mockAccountStorage.Object); - _mockSqLiteJsonCacheManagerFactory - .Setup(f => f.CreateForUser("AccountAddFlow")) - .Returns(_mockAccountAddLockStorage.Object); _accountManager = new AccountManager( - _mockApplication.Object, _mockLogger.Object, - _mockGraphQLClientFactory.Object, - _mockSpeckleHttp.Object, _mockAccountFactory, + _mockAuthFlow.Object, _mockSqLiteJsonCacheManagerFactory.Object ); } @@ -330,71 +318,6 @@ public sealed class AccountManagerTests : MoqTest ); } - [Fact] - public void GetLocalIdentifierForAccount_ReturnsIdentifier_WhenAccountExists() - { - // Arrange - var account = CreateTestAccount("test-account"); - var expectedUri = new Uri($"{account.serverInfo.url}?id={account.userInfo.id}"); - - _mockAccountStorage.Setup(s => s.GetAllObjects()).Returns(new[] { ("bad", JsonConvert.SerializeObject(account)) }); - - // Act - var result = _accountManager.GetLocalIdentifierForAccount(account); - - // Assert - Assert.NotNull(result); - Assert.Equal(expectedUri, result); - } - - [Fact] - public void GetLocalIdentifierForAccount_ReturnsNull_WhenAccountDoesNotExist() - { - // Arrange - var account = CreateTestAccount("non-existent-account"); - - _mockAccountStorage.Setup(s => s.GetAllObjects()).Returns([]); - - // Act - var result = _accountManager.GetLocalIdentifierForAccount(account); - - // Assert - Assert.Null(result); - } - - [Fact] - public void GetAccountForLocalIdentifier_ReturnsAccount_WhenMatches() - { - // Arrange - var account = CreateTestAccount("test-account"); - var localIdentifier = new Uri($"{account.serverInfo.url}?id={account.userInfo.id}"); - - _mockAccountStorage.Setup(s => s.GetAllObjects()).Returns(new[] { ("bad", JsonConvert.SerializeObject(account)) }); - - // Act - var result = _accountManager.GetAccountForLocalIdentifier(localIdentifier); - - // Assert - Assert.NotNull(result); - Assert.Equal(account.id, result!.id); - } - - [Fact] - public void GetAccountForLocalIdentifier_ReturnsNull_WhenNoMatch() - { - // Arrange - var account = CreateTestAccount("test-account"); - var localIdentifier = new Uri("https://different.url?u=different-user"); - - _mockAccountStorage.Setup(s => s.GetAllObjects()).Returns(new[] { ("bad", JsonConvert.SerializeObject(account)) }); - - // Act - var result = _accountManager.GetAccountForLocalIdentifier(localIdentifier); - - // Assert - Assert.Null(result); - } - // Helper method to create a test account private static Account CreateTestAccount(string id) { diff --git a/tests/Speckle.Sdk.Tests.Unit/Credentials/Accounts.cs b/tests/Speckle.Sdk.Tests.Unit/Credentials/Accounts.cs index 44607509..d9046d61 100644 --- a/tests/Speckle.Sdk.Tests.Unit/Credentials/Accounts.cs +++ b/tests/Speckle.Sdk.Tests.Unit/Credentials/Accounts.cs @@ -48,7 +48,7 @@ public class CredentialInfrastructure : IDisposable { Fixtures.UpdateOrSaveAccount(s_testAccount1); Fixtures.UpdateOrSaveAccount(s_testAccount2); - Fixtures.SaveLocalAccount(s_testAccount3); + Fixtures.UpdateOrSaveAccount(s_testAccount3); var serviceProvider = TestServiceSetup.GetServiceProvider(); _accountManager = serviceProvider.GetRequiredService(); @@ -60,7 +60,6 @@ public class CredentialInfrastructure : IDisposable Fixtures.DeleteLocalAccount(s_testAccount1.id); Fixtures.DeleteLocalAccount(s_testAccount2.id); Fixtures.DeleteLocalAccount(s_testAccount3.id); - Fixtures.DeleteLocalAccountFile(); } [Fact] @@ -93,7 +92,7 @@ public class CredentialInfrastructure : IDisposable { var accs = _accountManager.GetAccounts(target.serverInfo.url).ToList(); - accs.Count.Should().Be(1); + accs.Should().HaveCount(1); var acc = accs[0]; @@ -103,24 +102,4 @@ public class CredentialInfrastructure : IDisposable acc.refreshToken.Should().Be(target.refreshToken); acc.token.Should().Be(target.token); } - - [Fact] - public void EnsureLocalIdentifiers_AreUniqueAcrossServers() - { - // Accounts with the same user ID in different servers should always result in different local identifiers. - string id = "12345"; - var acc1 = new Account - { - serverInfo = new ServerInfo { url = "https://speckle.xyz" }, - userInfo = new UserInfo { id = id }, - }.GetLocalIdentifier(); - - var acc2 = new Account - { - serverInfo = new ServerInfo { url = "https://app.speckle.systems" }, - userInfo = new UserInfo { id = id }, - }.GetLocalIdentifier(); - - acc1.Should().NotBe(acc2); - } } diff --git a/tests/Speckle.Sdk.Tests.Unit/Credentials/AuthFlowTests.cs b/tests/Speckle.Sdk.Tests.Unit/Credentials/AuthFlowTests.cs new file mode 100644 index 00000000..44397f3b --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Unit/Credentials/AuthFlowTests.cs @@ -0,0 +1,36 @@ +using Speckle.Sdk.Credentials; + +namespace Speckle.Sdk.Tests.Unit.Credentials; + +public class AuthFlowTests +{ + private const int REPEAT = 20; + + [Fact] + public void GenerateChallenge_ReturnsValidUniqueChallenge() + { + var codeVerifiers = Enumerable.Range(0, REPEAT).Select(_ => AuthFlow.GenerateCodeVerifier()).ToArray(); + + Assert.All( + codeVerifiers, + item => + { + Assert.Equal(43, item.Length); + Assert.Matches(@"^[A-Za-z0-9\-_+/]*$", item); + } + ); + + Assert.Equivalent(codeVerifiers, codeVerifiers.Distinct()); + var challenges = codeVerifiers.Select(AuthFlow.GenerateCodeChallenge).ToArray(); + + Assert.All( + challenges, + item => + { + Assert.Equal(43, item.Length); + Assert.Matches(@"^[A-Za-z0-9\-_+/]*$", item); + } + ); + Assert.Equivalent(challenges, challenges.Distinct()); + } +} diff --git a/tests/Speckle.Sdk.Tests.Unit/Fixtures.cs b/tests/Speckle.Sdk.Tests.Unit/Fixtures.cs index 65f49f12..596a3c9e 100644 --- a/tests/Speckle.Sdk.Tests.Unit/Fixtures.cs +++ b/tests/Speckle.Sdk.Tests.Unit/Fixtures.cs @@ -1,7 +1,6 @@ using Newtonsoft.Json; using Speckle.Sdk.Common; using Speckle.Sdk.Credentials; -using Speckle.Sdk.Logging; using Speckle.Sdk.Transports; namespace Speckle.Sdk.Tests.Unit; @@ -10,11 +9,6 @@ public abstract class Fixtures { private static readonly SQLiteTransport s_accountStorage = new(scope: "Accounts"); - private static readonly string s_accountPath = Path.Combine( - SpecklePathProvider.AccountsFolderPath, - "TestAccount.json" - ); - public static void UpdateOrSaveAccount(Account account) { DeleteLocalAccount(account.id.NotNull()); @@ -22,13 +16,5 @@ public abstract class Fixtures s_accountStorage.SaveObjectSync(account.id, serializedObject); } - public static void SaveLocalAccount(Account account) - { - var json = JsonConvert.SerializeObject(account); - File.WriteAllText(s_accountPath, json); - } - public static void DeleteLocalAccount(string id) => s_accountStorage.DeleteObject(id); - - public static void DeleteLocalAccountFile() => File.Delete(s_accountPath); }