Compare commits

...

8 Commits

Author SHA1 Message Date
Jedd Morgan 74d40e40a9 Optimization for the disk store string writing to avoid memory allocations (#466)
.NET Build and Publish / build (push) Has been cancelled
2026-03-31 10:07:08 +00:00
dependabot[bot] c81692ee5a chore(deps): bump codecov/codecov-action from 5 to 6 (#465)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 21:42:44 +01:00
Jedd Morgan 7f50987201 Auth flow updates (#464)
.NET Build and Publish / build (push) Has been cancelled
* Auth flow updates

* remove obsolete flag on refresh
2026-03-26 14:42:21 +01:00
Jedd Morgan 7042cdb06a feat(auth): Add new authflow functions (#462)
.NET Build and Publish / build (push) Has been cancelled
* Auth functions

* Avoid Microsoft.BCL.Memory polyfil

* refactor updating accounts

* fix tests

* Fix tests

* better auth messages

* Remote changes

* Fix tests

* Fix tests

* ditto

* typo

* More tests

* fix exception types

* revert temp change to test url

* use new endpoint

* new endpoint

* Revert temp change to server url

* Fix mistakes in tests

* using

* extra tests

* test server url

* no need to repack bcl memory since it's not a dependencgy

* Avoid parallelising account manager tests

* potential fix

* disable parallelisation for authflow tests
2026-03-24 17:17:25 +00:00
Jedd Morgan af0fc9f669 Merge pull request #439 from specklesystems/duckdev
.NET Build and Publish / build (push) Has been cancelled
feat(packfile): duckdb packfile api (duckdev -> main)
2026-03-23 11:08:57 +00:00
Jedd Morgan edbc884d74 Clean diff 2026-03-23 10:45:57 +00:00
Jedd Morgan 025d7f70ba Merge branch 'main' into duckdev 2026-03-23 10:41:22 +00:00
dependabot[bot] abf86eda03 chore(deps): bump docker/login-action from 3 to 4 (#456)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 09:50:34 +00:00
22 changed files with 849 additions and 744 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
- name: 🔐 Login to Github Container Registry
if: ${{ inputs.use-internal-image }}
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: "ghcr.io"
username: ${{ github.actor }}
@@ -54,7 +54,7 @@ jobs:
run: dotnet test ${{ env.Solution }} --filter "(Category=Integration)&(Server!=Public)" --configuration Release --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
continue-on-error: true
with:
fail_ci_if_error: true
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
run: dotnet pack ${{ env.Solution }} --configuration Release --no-build
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
continue-on-error: true
with:
fail_ci_if_error: true
+2 -2
View File
@@ -154,10 +154,10 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
activity?.SetStatus(SdkActivityStatusCode.Ok);
return ret;
}
catch (Exception)
catch (Exception ex)
{
activity?.SetStatus(SdkActivityStatusCode.Error);
// Don't record exception as it's rethrown.
activity?.RecordException(ex);
throw;
}
}
+2 -32
View File
@@ -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<Account>
{
private string _id;
@@ -37,6 +34,8 @@ public class Account : IEquatable<Account>
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<Account>
}
#endregion
internal const string LOCAL_IDENTIFIER_DEPRECATION_MESSAGE = "Local identifiers no longer nesseary";
/// <summary>
/// Retrieves the local identifier for the current user.
/// </summary>
/// <returns>
/// Returns a <see cref="Uri"/> 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.
/// </returns>
/// <remarks>
/// 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.
/// </remarks>
/// <example>
/// This sample shows how to call the GetLocalIdentifier method.
/// <code>
/// Uri localIdentifier = GetLocalIdentifier();
/// Console.WriteLine(localIdentifier);
/// </code>
/// For a fictional `User ID: 123` and `Server: https://speckle.xyz`, the output might look like this:
/// <code>
/// https://speckle.xyz?id=123
/// </code>
/// </example>
[Obsolete(LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
internal Uri GetLocalIdentifier() => new($"{serverInfo.url}?id={userInfo.id}");
}
+116 -544
View File
@@ -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;
/// <summary>
/// Manage accounts locally for desktop applications.
/// Manages <see cref="Account"/> data in the local sqlite account store
/// </summary>
[GenerateAutoInterface]
public sealed class AccountManager(
ISpeckleApplication application,
ILogger<AccountManager> 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();
}
/// <summary>
/// Gets the basic information about a server.
/// </summary>
/// <param name="server">Server Information</param>
/// <returns></returns>
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (received a non-successful response code)</exception>
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
public async Task<ServerInfo> 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<ServerInfoResponse>(request, cancellationToken).ConfigureAwait(false);
response.EnsureGraphQLSuccess();
ServerInfo serverInfo = response.Data.serverInfo;
serverInfo.url = server.ToString().TrimEnd('/');
return response.Data.serverInfo;
}
/// <summary>
/// Gets basic user information given a token and a server.
/// </summary>
/// <param name="token"></param>
/// <param name="server">Server URL</param>
/// <returns></returns>
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (received a non-successful response code)</exception>
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
public async Task<UserInfo> 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<RequiredResponse<UserInfo>>(request, cancellationToken)
.ConfigureAwait(false);
response.EnsureGraphQLSuccess();
return response.Data.data;
}
/// <summary>
/// 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
/// </summary>
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);
}
/// <param name="id">The Id of the account to fetch</param>
@@ -151,37 +41,6 @@ public sealed class AccountManager(
?? throw new SpeckleAccountManagerException($"Account {id} not found");
}
/// <summary>
/// Upgrades an account from the account.serverInfo.movedFrom account to the account.serverInfo.movedTo account
/// </summary>
/// <param name="id">Id of the account to upgrade</param>
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<Account> GetAccounts(string serverUrl)
{
return GetAccounts(new Uri(serverUrl));
}
/// <summary>
/// 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<Account>(x.Json));
var localAccounts = GetLocalAccounts();
foreach (var acc in sqlAccounts)
{
@@ -259,119 +117,55 @@ public sealed class AccountManager(
yield return acc;
}
}
foreach (var acc in localAccounts)
{
yield return acc;
}
}
/// <summary>
/// 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 <see cref="ServerInfo"/> and <see cref="UserInfo"/>.
/// If the <see cref="Account.token"/> looks to be expired, this function will also attempt to use the <see cref="Account.refreshToken"/> to refresh it.
/// Will write the changes to the local accounts db
/// </summary>
/// <returns></returns>
private IList<Account> GetLocalAccounts()
/// <seealso cref="UpdateAccount"/>
/// <param name="cancellationToken"></param>
/// <exception cref="AggregateException"></exception>
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<Account>();
// ID may have changed, e.g. users email changed, or server url migrated
_accountStorage.DeleteObject(oldAccountId);
}
var accounts = new List<Account>();
string[] files = Directory.GetFiles(accountsDir, "*.json", SearchOption.AllDirectories);
foreach (var file in files)
{
try
{
var json = File.ReadAllText(file);
Account? account = JsonConvert.DeserializeObject<Account>(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));
}
/// <summary>
/// Refetches user and server info for each account
/// </summary>
/// <param name="app"> It is defaultAppId in the server. By default it is "sca" to not break existing parts that this function involves.</param>
/// <returns></returns>
public async Task UpdateAccounts(CancellationToken ct = default, string app = "sca")
{
// 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())
{
try
{
Uri url = new(account.serverInfo.url);
var userServerInfo = await accountFactory.GetUserServerInfo(url, account.token, ct).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;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (!ex.IsFatal())
{
await RefreshAndSetAccountToken(account, app).ConfigureAwait(false);
}
ct.ThrowIfCancellationRequested();
_accountStorage.UpdateObject(account.id, JsonConvert.SerializeObject(account));
}
}
/// <summary>
/// Mutates the account with new tokens.
/// Refetches the <paramref name="account"/> information, including <see cref="ServerInfo"/> and <see cref="UserInfo"/>
///
/// Will only mutate <paramref name="account"/> in memory only, and only if successful.
/// </summary>
/// <seealso cref="UpdateAccount"/>
/// <param name="account"></param>
/// <param name="app"></param>
private async Task RefreshAndSetAccountToken(Account account, string app)
/// <param name="cancellationToken"></param>
/// <exception cref="GraphQLHttpRequestException"></exception>
public async Task UpdateAccountInMemory(Account account, CancellationToken cancellationToken = default)
{
try
Uri url = account.serverInfo.migration?.movedTo ?? new(account.serverInfo.url);
ActiveUserServerInfoResponse userServerInfo = await accountFactory
.GetUserServerInfo(url, account.token, cancellationToken)
.ConfigureAwait(false);
if (userServerInfo.activeUser == null)
{
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;
throw new SpeckleException("GraphQL response indicated that the ActiveUser could not be found");
}
account.userInfo = userServerInfo.activeUser;
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!;
}
/// <summary>
@@ -412,325 +206,103 @@ public sealed class AccountManager(
}
/// <summary>
/// Retrieves the local identifier for the specified account.
/// Adds an account to local storage by prompting the user to log in via their browser.
/// </summary>
/// <param name="account">The account for which to retrieve the local identifier.</param>
/// <returns>The local identifier for the specified account in the form of "SERVER_URL?u=USER_ID".</returns>
/// <remarks>
/// <inheritdoc cref="Account.GetLocalIdentifier"/>
/// </remarks>
[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<UserInfo> Validate(Account account)
{
Uri server = new(account.serverInfo.url);
return await GetUserInfo(account.token, server).ConfigureAwait(false);
}
/// <summary>
/// Gets the account that corresponds to the given local identifier.
/// </summary>
/// <param name="localIdentifier">The local identifier of the account.</param>
/// <returns>The account that matches the local identifier, or null if no match is found.</returns>
[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<string> 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!<br/><br/>You can close this window now.<script>window.close();</script>"
: "Oups, something went wrong...!";
var responseString =
$"<HTML><BODY Style='background: linear-gradient(to top right, #ffffff, #c8e8ff); font-family: Roboto, sans-serif; font-size: 2rem; font-weight: 500; text-align: center;'><br/>{message}</BODY></HTML>";
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<Account> 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);
}
}
/// <summary>
/// Adds an account by propting the user to log in via a web flow
/// </summary>
/// <param name="server">Server to use to add the account, if not provied the default Server will be used</param>
/// <example>
/// <code>
/// Account account = await AuthenticateAccount(new Uri("https://app.speckle.systems"), TimeSpan.FromMinutes(1));
/// </code>
/// </example>
/// <param name="serverUrl"></param>
/// <param name="timeout">Timeout for user to auth with browser, recommend 1 min timeout</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task AddAccount(Uri? server = null)
public async Task<Account> 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<Account> 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;
}
/// <summary>
/// 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
/// </summary>
[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<TokenExchangeResponse> GetToken(string accessCode, string challenge, Uri server)
[Obsolete("Use Uri overload")]
public IEnumerable<Account> 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<TokenExchangeResponse>(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<TokenExchangeResponse> 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<TokenExchangeResponse>(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<ServerInfo> 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<UserInfo> 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<Account> GetLocalAccounts() => throw new NotImplementedException();
[Obsolete("Use UpdateAccount or UpdateAccountInMemory Instead", true)]
public IList<Account> Validate() => throw new NotImplementedException();
}
+13
View File
@@ -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"),
};
}
+330
View File
@@ -0,0 +1,330 @@
using System.Diagnostics;
using System.Diagnostics.Contracts;
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;
/// <summary>
/// Authentication flow with the Speckle Server to create a application token for the <c>connectorsV3</c> 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 <see cref="HttpListener"/> which will be exchanged
/// for a <see cref="TokenExchangeResponse"/>
/// </summary>
/// <remarks>
/// Note, this class is not coupled in any way to <see cref="Account"/>
/// lets keep it that way...
/// See instead <see cref="AccountManager"/>
/// </remarks>
[GenerateAutoInterface]
public sealed class AuthFlow(ISdkActivityFactory activityFactory, ISpeckleHttp speckleHttp) : IAuthFlow
{
private readonly JsonSerializerSettings _serializerSettings = new()
{
MissingMemberHandling = MissingMemberHandling.Error,
NullValueHandling = NullValueHandling.Ignore,
};
public async Task<TokenExchangeResponse> TriggerAuthFlowWithTimeout(
Uri serverUrl,
AuthApp authApp,
TimeSpan timeout,
CancellationToken cancellationToken
)
{
using HttpClient client = speckleHttp.CreateHttpClient();
Uri tokenEndpoint = new(serverUrl, "/oauth/token");
string codeVerifier = GenerateCodeVerifier();
Uri authnVerify;
using var req = await client.GetAsync(tokenEndpoint, cancellationToken).ConfigureAwait(false);
bool useLegacyEndpoint = req.StatusCode != HttpStatusCode.OK;
if (useLegacyEndpoint)
{
string challenge = codeVerifier; // Old endpoint only supports PKCE "plain" method
authnVerify = new($"/authn/verify/{authApp.AppId}/{challenge}", UriKind.Relative);
tokenEndpoint = new(serverUrl, "/auth/token");
}
else
{
string challenge = GenerateCodeChallenge(codeVerifier);
authnVerify = new($"/authn/verify/{authApp.AppId}/{challenge}?code_challenge_method=S256", UriKind.Relative);
}
Uri endpoint = new(serverUrl, authnVerify);
_ = Process.Start(new ProcessStartInfo(endpoint.ToString()) { UseShellExecute = true });
string accessCode = await RunListenerWithTimeout(authApp.CallbackUrl, timeout, cancellationToken)
.ConfigureAwait(false);
object body = useLegacyEndpoint
? new
{
appId = authApp.AppId,
appSecret = authApp.AppSecret,
accessCode = accessCode,
challenge = codeVerifier,
}
: new
{
appId = authApp.AppId,
accessCode = accessCode,
codeVerifier = codeVerifier,
};
return await ExchangeAccessCodeForToken(
client,
JsonConvert.SerializeObject(body, _serializerSettings),
tokenEndpoint,
cancellationToken
)
.ConfigureAwait(false);
}
/// <summary>
///
/// </summary>
/// <param name="applicationCallbackUrl"></param>
/// <param name="timeout"></param>
/// <param name="userCancellation"></param>
/// <returns></returns>
/// <exception cref="OperationCanceledException"><paramref name="userCancellation"/> requested cancel</exception>
/// <exception cref="TimeoutException">timeout was reached</exception>
public async Task<string> 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);
}
}
/// <summary>
///
/// </summary>
/// <param name="refreshToken"></param>
/// <param name="serverUrl"></param>
/// <param name="authApp">Auth app, needs to match the app that generated the refresh token originally</param>
/// <param name="cancellationToken"></param>
/// <exception cref="HttpRequestException">HTTP exceptions</exception>
/// <exception cref="JsonSerializationException">Server response was invalid or partial</exception>
/// <exception cref="ArgumentOutOfRangeException ">Invalid <paramref name="serverUrl"/> (must be absolute url)</exception>
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> requested cancel</exception>
/// <returns></returns>
public async Task<TokenExchangeResponse> 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, _serializerSettings));
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<TokenExchangeResponse>(read, _serializerSettings).NotNull();
}
private static async Task<HttpListenerContext> 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<HttpListenerContext> 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<string> 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(
"""
<h1>Denied!</h1>
<br/><br/>
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(
"""
<h1>Success!</h1>
<br/><br/>
Your Speckle Connector is now authorized
<br/><br/>
You may now close this window and return to your Speckle Connector
"""
);
return accessCode;
}
else
{
//lang=html
WriteResponse(
"""
<h1>Failed!</h1>
<br/><br/>
Something went wrong trying to authorize your Speckle Connector
<br/><br/>
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 = $"""
<HTML>
<BODY Style='background: #FAFAFAFF; font-family: Inter, Roboto, sans-serif; font-size: 1rem; font-weight: 500; text-align: center;'>
<br/>
{message}
</BODY>
</HTML>
""";
byte[] buffer = Encoding.UTF8.GetBytes(responseString);
response.ContentLength64 = buffer.Length;
response.OutputStream.Write(buffer, 0, buffer.Length);
}
}
private async Task<TokenExchangeResponse> ExchangeAccessCodeForToken(
HttpClient client,
string jsonContent,
Uri tokenEndpoint,
CancellationToken cancellationToken
)
{
using StringContent content = new(jsonContent);
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<TokenExchangeResponse>(read, _serializerSettings).NotNull();
}
[Pure]
public static string GenerateCodeVerifier()
{
#if NET8_0_OR_GREATER
Span<byte> codeVerifierData = stackalloc byte[32];
RandomNumberGenerator.Fill(codeVerifierData);
#else
using RNGCryptoServiceProvider rng = new();
byte[] codeVerifierData = new byte[32];
rng.GetBytes(codeVerifierData);
#endif
return Base64UrlEncode(codeVerifierData);
}
[Pure]
public static string GenerateCodeChallenge(string codeVerifier)
{
#if NET8_0_OR_GREATER
int byteCount = Encoding.UTF8.GetByteCount(codeVerifier.AsSpan());
Span<byte> codeVerifierBytes = stackalloc byte[byteCount];
Encoding.UTF8.GetBytes(codeVerifier, codeVerifierBytes);
Span<byte> 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);
}
[Pure]
private static string Base64UrlEncode(
#if NET8_0_OR_GREATER
ReadOnlySpan<byte> 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('=');
}
}
@@ -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() { }
}
+11 -11
View File
@@ -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) { }
}
+8 -5
View File
@@ -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
@@ -7,32 +7,33 @@ namespace Speckle.Sdk.Models;
public enum DynamicBaseMemberType
{
/// <summary>
/// The typed members of the DynamicBase object
/// The typed members of the <see cref="DynamicBase"/> object
/// </summary>
Instance = 1,
/// <summary>
/// The dynamically added members of the DynamicBase object
/// The dynamically added members of the <see cref="DynamicBase"/> object
/// </summary>
Dynamic = 2,
/// <summary>
/// The typed members flagged with ObsoleteAttribute attribute.
/// The typed members flagged with <see cref="ObsoleteAttribute"/> attribute.
/// </summary>
Obsolete = 4,
/// <summary>
/// The typed methods flagged with TODO:
/// Old feature supported in v2 for grasshopper
/// </summary>
[Obsolete("Feature no longer supported")]
SchemaComputed = 16,
/// <summary>
/// All the typed members, including ones with ObsoleteAttribute attributes.
/// All the typed members, including ones with <see cref="ObsoleteAttribute"/> attributes.
/// </summary>
InstanceAll = Instance + Obsolete,
/// <summary>
/// All the members, including dynamic and instance members flagged with ObsoleteAttribute attributes
/// All the members, including dynamic and instance members flagged with <see cref="ObsoleteAttribute"/> attributes
/// </summary>
All = InstanceAll + Dynamic,
}
+6 -1
View File
@@ -65,7 +65,12 @@ public sealed class DiskStore
await foreach (var item in _channel.ReadAllAsync(_cancellationToken).ConfigureAwait(false))
{
await writer.WriteLineAsync($"{item.Id}\t{item.SpeckleType}\t{item.Json}").ConfigureAwait(false);
await writer.WriteAsync(item.Id).ConfigureAwait(false);
await writer.WriteAsync('\t').ConfigureAwait(false);
await writer.WriteAsync(item.SpeckleType).ConfigureAwait(false);
await writer.WriteAsync('\t').ConfigureAwait(false);
await writer.WriteAsync(item.Json.Value).ConfigureAwait(false);
await writer.WriteLineAsync().ConfigureAwait(false);
}
#if NET8_0_OR_GREATER
await writer.FlushAsync(_cancellationToken).ConfigureAwait(false);
@@ -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<AggregateException>(async () => _ = await Sut.Get("non-existent-id"));
Assert.Single(ex.InnerExceptions);
Assert.All(ex.InnerExceptions, item => Assert.IsType<SpeckleGraphQLForbiddenException>(item));
ex.InnerExceptions.Should().HaveCount(1);
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLForbiddenException>();
}
[Fact]
public async Task TestGetProjects()
{
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.GetProjects("non-existent-id"));
Assert.Single(ex.InnerExceptions);
Assert.All(ex.InnerExceptions, item => Assert.IsType<SpeckleGraphQLForbiddenException>(item));
ex.InnerExceptions.Should().HaveCount(1);
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLForbiddenException>();
}
}
@@ -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;
@@ -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<IAccountManager>();
}
[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);
}
}
@@ -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<OperationCanceledException>(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<HttpRequestException>(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<HttpRequestException>(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<HttpListenerException>(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<OperationCanceledException>(async () => await task1);
}
public async Task InitializeAsync()
{
_authFlow = Fixtures.ServiceProvider.GetRequiredService<IAuthFlow>();
_client = await Fixtures.SeedUserWithClient();
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
}
@@ -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<IAuthFlow>();
}
[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<AuthFlowException>(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<OperationCanceledException>(async () =>
{
_ = await listenerTask;
});
}
[Theory]
[InlineData(0.1)]
[InlineData(1)]
[InlineData(5)]
public async Task RunListener_Timeout(double timeS)
{
await Assert.ThrowsAsync<TimeoutException>(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);
}
}
@@ -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<string, string> 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<IAccountFactory>().CreateAccount(new(Server.url), token);
return await ServiceProvider
.GetRequiredService<IAccountFactory>()
.CreateAccount(new(Server.url), token, refreshToken);
}
public static Base GenerateSimpleObject()
@@ -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<ISpeckleApplication> _mockApplication;
private readonly Mock<ILogger<AccountManager>> _mockLogger;
private readonly Mock<IGraphQLClientFactory> _mockGraphQLClientFactory;
private readonly Mock<ISpeckleHttp> _mockSpeckleHttp;
private readonly IAccountFactory _mockAccountFactory;
private readonly Mock<ISqLiteJsonCacheManagerFactory> _mockSqLiteJsonCacheManagerFactory;
private readonly Mock<ISqLiteJsonCacheManager> _mockAccountStorage;
private readonly Mock<ISqLiteJsonCacheManager> _mockAccountAddLockStorage;
private readonly Mock<IAuthFlow> _mockAuthFlow;
#pragma warning disable CA2213
private readonly AccountManager _accountManager;
@@ -43,27 +39,19 @@ public sealed class AccountManagerTests : MoqTest
public AccountManagerTests()
{
_mockApplication = Create<ISpeckleApplication>();
_mockLogger = Create<ILogger<AccountManager>>(MockBehavior.Loose);
_mockGraphQLClientFactory = Create<IGraphQLClientFactory>();
_mockSpeckleHttp = Create<ISpeckleHttp>();
_mockAccountFactory = new TestAccountFactory();
_mockSqLiteJsonCacheManagerFactory = Create<ISqLiteJsonCacheManagerFactory>();
_mockAuthFlow = Create<IAuthFlow>();
_mockAccountStorage = Create<ISqLiteJsonCacheManager>();
_mockAccountAddLockStorage = Create<ISqLiteJsonCacheManager>();
_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)
{
@@ -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<IAccountManager>();
@@ -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);
}
}
@@ -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());
}
}
-14
View File
@@ -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);
}