Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c517dead03 | |||
| 2b61ab7d2e | |||
| 4b319499c3 | |||
| d4055c6ff1 |
@@ -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
|
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
|
- name: Upload coverage reports to Codecov with GitHub Action
|
||||||
uses: codecov/codecov-action@v6
|
uses: codecov/codecov-action@v5
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
run: dotnet pack ${{ env.Solution }} --configuration Release --no-build
|
run: dotnet pack ${{ env.Solution }} --configuration Release --no-build
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov with GitHub Action
|
- name: Upload coverage reports to Codecov with GitHub Action
|
||||||
uses: codecov/codecov-action@v6
|
uses: codecov/codecov-action@v5
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
|
|||||||
@@ -154,10 +154,10 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
|
|||||||
activity?.SetStatus(SdkActivityStatusCode.Ok);
|
activity?.SetStatus(SdkActivityStatusCode.Ok);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
activity?.SetStatus(SdkActivityStatusCode.Error);
|
activity?.SetStatus(SdkActivityStatusCode.Error);
|
||||||
activity?.RecordException(ex);
|
// Don't record exception as it's rethrown.
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Speckle.Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Speckle.Sdk.Api.GraphQL.Models;
|
namespace Speckle.Sdk.Api.GraphQL.Models;
|
||||||
|
|
||||||
public class LimitedWorkspace
|
public class LimitedWorkspace
|
||||||
@@ -6,8 +8,12 @@ public class LimitedWorkspace
|
|||||||
public string name { get; init; }
|
public string name { get; init; }
|
||||||
public string? role { get; init; }
|
public string? role { get; init; }
|
||||||
public string slug { get; init; }
|
public string slug { get; init; }
|
||||||
public string? logo { get; init; }
|
public string? logoUri { get; init; }
|
||||||
public string? description { get; init; }
|
public string? description { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
[Obsolete($"Deprecated, use {nameof(logoUri)} instead", true)]
|
||||||
|
public string? logo { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Workspace : LimitedWorkspace
|
public class Workspace : LimitedWorkspace
|
||||||
@@ -16,9 +22,13 @@ public class Workspace : LimitedWorkspace
|
|||||||
public DateTime updatedAt { get; init; }
|
public DateTime updatedAt { get; init; }
|
||||||
public bool readOnly { get; init; }
|
public bool readOnly { get; init; }
|
||||||
public WorkspacePermissionChecks permissions { get; init; }
|
public WorkspacePermissionChecks permissions { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
[Obsolete("Workspaces no longer have creation state, is always created true", true)]
|
||||||
public WorkspaceCreationState? creationState { get; init; }
|
public WorkspaceCreationState? creationState { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Obsolete("Workspaces no longer have creation state, is always created true")]
|
||||||
public sealed class WorkspaceCreationState
|
public sealed class WorkspaceCreationState
|
||||||
{
|
{
|
||||||
public bool completed { get; init; }
|
public bool completed { get; init; }
|
||||||
|
|||||||
@@ -264,15 +264,11 @@ public sealed class ActiveUserResource
|
|||||||
name
|
name
|
||||||
role
|
role
|
||||||
slug
|
slug
|
||||||
logo
|
logoUrl
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
readOnly
|
readOnly
|
||||||
description
|
description
|
||||||
creationState
|
|
||||||
{
|
|
||||||
completed
|
|
||||||
}
|
|
||||||
permissions {
|
permissions {
|
||||||
canCreateProject {
|
canCreateProject {
|
||||||
authorized
|
authorized
|
||||||
@@ -317,7 +313,7 @@ public sealed class ActiveUserResource
|
|||||||
/// <remarks>note this returns a <see cref="LimitedWorkspace"/>, because it may be a workspace the user is not a member of</remarks>
|
/// <remarks>note this returns a <see cref="LimitedWorkspace"/>, because it may be a workspace the user is not a member of</remarks>
|
||||||
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
|
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
|
||||||
/// <exception cref="SpeckleException">The ActiveUser could not be found (e.g. the client is not authenticated)</exception>
|
/// <exception cref="SpeckleException">The ActiveUser could not be found (e.g. the client is not authenticated)</exception>
|
||||||
public async Task<LimitedWorkspace?> GetActiveWorkspace(CancellationToken cancellationToken = default)
|
private async Task<LimitedWorkspace?> GetActiveWorkspace_Legacy(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
//language=graphql
|
//language=graphql
|
||||||
const string QUERY = """
|
const string QUERY = """
|
||||||
@@ -328,7 +324,6 @@ public sealed class ActiveUserResource
|
|||||||
name
|
name
|
||||||
role
|
role
|
||||||
slug
|
slug
|
||||||
logo
|
|
||||||
description
|
description
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,6 +344,47 @@ public sealed class ActiveUserResource
|
|||||||
return response.data.data;
|
return response.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<LimitedWorkspace?> GetActiveWorkspace(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
//language=graphql
|
||||||
|
const string QUERY = """
|
||||||
|
query ActiveUser {
|
||||||
|
data:activeUser {
|
||||||
|
data:activeWorkspace {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
role
|
||||||
|
slug
|
||||||
|
logoUrl
|
||||||
|
description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var request = new GraphQLRequest { Query = QUERY };
|
||||||
|
|
||||||
|
NullableResponse<NullableResponse<LimitedWorkspace?>?> response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await _client
|
||||||
|
.ExecuteGraphQLRequest<NullableResponse<NullableResponse<LimitedWorkspace?>?>>(request, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (SpeckleGraphQLInvalidQueryException)
|
||||||
|
{
|
||||||
|
//v2.x.x servers do not have a logoUrl property
|
||||||
|
return await GetActiveWorkspace_Legacy(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data is null)
|
||||||
|
{
|
||||||
|
throw new SpeckleException("GraphQL response indicated that the ActiveUser could not be found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
/// <param name="limit">Max number of projects to fetch</param>
|
/// <param name="limit">Max number of projects to fetch</param>
|
||||||
/// <param name="cursor">Optional cursor for pagination</param>
|
/// <param name="cursor">Optional cursor for pagination</param>
|
||||||
/// <param name="filter">Optional filter</param>
|
/// <param name="filter">Optional filter</param>
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ public sealed class OtherUserResource
|
|||||||
/// <param name="query">String to search for. Must be at least 3 characters</param>
|
/// <param name="query">String to search for. Must be at least 3 characters</param>
|
||||||
/// <param name="limit">Max number of users to fetch</param>
|
/// <param name="limit">Max number of users to fetch</param>
|
||||||
/// <param name="cursor">Optional cursor for pagination</param>
|
/// <param name="cursor">Optional cursor for pagination</param>
|
||||||
/// <param name="archived"></param>
|
|
||||||
/// <param name="emailOnly"></param>
|
/// <param name="emailOnly"></param>
|
||||||
/// <param name="cancellationToken"></param>
|
/// <param name="cancellationToken"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
@@ -61,26 +60,25 @@ public sealed class OtherUserResource
|
|||||||
string query,
|
string query,
|
||||||
int limit = ServerLimits.DEFAULT_PAGINATION_REQUEST,
|
int limit = ServerLimits.DEFAULT_PAGINATION_REQUEST,
|
||||||
string? cursor = null,
|
string? cursor = null,
|
||||||
bool archived = false,
|
|
||||||
bool emailOnly = false,
|
bool emailOnly = false,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
//language=graphql
|
//language=graphql
|
||||||
const string QUERY = """
|
const string QUERY = """
|
||||||
query UserSearch($query: String!, $limit: Int!, $cursor: String, $archived: Boolean, $emailOnly: Boolean) {
|
query Users($input: UsersRetrievalInput!) {
|
||||||
data:userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived, emailOnly: $emailOnly) {
|
data:users(input: $input) {
|
||||||
cursor
|
cursor
|
||||||
items {
|
items {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
bio
|
bio
|
||||||
company
|
company
|
||||||
avatar
|
avatar
|
||||||
verified
|
verified
|
||||||
role
|
role
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -89,11 +87,13 @@ public sealed class OtherUserResource
|
|||||||
Query = QUERY,
|
Query = QUERY,
|
||||||
Variables = new
|
Variables = new
|
||||||
{
|
{
|
||||||
query,
|
input = new
|
||||||
limit,
|
{
|
||||||
cursor,
|
query,
|
||||||
archived,
|
limit,
|
||||||
emailOnly,
|
emailOnly,
|
||||||
|
cursor,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ public sealed class SubscriptionResource : IDisposable
|
|||||||
/// <summary>Subscribe to updates to resource comments/threads. Optionally specify resource ID string to only receive updates regarding comments for those resources</summary>
|
/// <summary>Subscribe to updates to resource comments/threads. Optionally specify resource ID string to only receive updates regarding comments for those resources</summary>
|
||||||
/// <remarks><inheritdoc cref="CreateUserProjectsUpdatedSubscription"/></remarks>
|
/// <remarks><inheritdoc cref="CreateUserProjectsUpdatedSubscription"/></remarks>
|
||||||
/// <inheritdoc cref="ISpeckleGraphQLClient.SubscribeTo{T}"/>
|
/// <inheritdoc cref="ISpeckleGraphQLClient.SubscribeTo{T}"/>
|
||||||
|
[Obsolete("Comments are now issues, and we've not update SDKs with the new subs")]
|
||||||
public Subscription<ProjectCommentsUpdatedMessage> CreateProjectCommentsUpdatedSubscription(
|
public Subscription<ProjectCommentsUpdatedMessage> CreateProjectCommentsUpdatedSubscription(
|
||||||
ViewerUpdateTrackingTarget target
|
ViewerUpdateTrackingTarget target
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,15 +28,11 @@ public sealed class WorkspaceResource
|
|||||||
name
|
name
|
||||||
role
|
role
|
||||||
slug
|
slug
|
||||||
logo
|
logoUrl
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
readOnly
|
readOnly
|
||||||
description
|
description
|
||||||
creationState
|
|
||||||
{
|
|
||||||
completed
|
|
||||||
}
|
|
||||||
permissions {
|
permissions {
|
||||||
canCreateProject {
|
canCreateProject {
|
||||||
authorized
|
authorized
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
using Speckle.Sdk.Api.GraphQL.Models;
|
using Speckle.Sdk.Api.GraphQL.Models;
|
||||||
using Speckle.Sdk.Common;
|
using Speckle.Sdk.Common;
|
||||||
|
|
||||||
namespace Speckle.Sdk.Credentials;
|
namespace Speckle.Sdk.Credentials;
|
||||||
|
|
||||||
|
[ClassInterface(ClassInterfaceType.AutoDual)]
|
||||||
|
[ComVisible(true)]
|
||||||
public class Account : IEquatable<Account>
|
public class Account : IEquatable<Account>
|
||||||
{
|
{
|
||||||
private string _id;
|
private string _id;
|
||||||
@@ -34,8 +37,6 @@ public class Account : IEquatable<Account>
|
|||||||
public string? refreshToken { get; set; }
|
public string? refreshToken { get; set; }
|
||||||
|
|
||||||
public bool isDefault { get; set; }
|
public bool isDefault { get; set; }
|
||||||
|
|
||||||
[Obsolete("Not used in v3")]
|
|
||||||
public bool isOnline { get; set; } = true;
|
public bool isOnline { get; set; } = true;
|
||||||
|
|
||||||
public ServerInfo serverInfo { get; set; }
|
public ServerInfo serverInfo { get; set; }
|
||||||
@@ -100,4 +101,33 @@ public class Account : IEquatable<Account>
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#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}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,145 @@
|
|||||||
|
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 GraphQL.Client.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Speckle.InterfaceGenerator;
|
using Speckle.InterfaceGenerator;
|
||||||
using Speckle.Newtonsoft.Json;
|
using Speckle.Newtonsoft.Json;
|
||||||
|
using Speckle.Sdk.Api.GraphQL;
|
||||||
using Speckle.Sdk.Api.GraphQL.Models;
|
using Speckle.Sdk.Api.GraphQL.Models;
|
||||||
|
using Speckle.Sdk.Api.GraphQL.Models.Responses;
|
||||||
using Speckle.Sdk.Common;
|
using Speckle.Sdk.Common;
|
||||||
|
using Speckle.Sdk.Helpers;
|
||||||
using Speckle.Sdk.Logging;
|
using Speckle.Sdk.Logging;
|
||||||
using Speckle.Sdk.SQLite;
|
using Speckle.Sdk.SQLite;
|
||||||
|
using Stream = System.IO.Stream;
|
||||||
|
|
||||||
namespace Speckle.Sdk.Credentials;
|
namespace Speckle.Sdk.Credentials;
|
||||||
|
|
||||||
public partial interface IAccountManager : IDisposable;
|
public partial interface IAccountManager : IDisposable;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manages <see cref="Account"/> data in the local sqlite account store
|
/// Manage accounts locally for desktop applications.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[GenerateAutoInterface]
|
[GenerateAutoInterface]
|
||||||
public sealed class AccountManager(
|
public sealed class AccountManager(
|
||||||
|
ISpeckleApplication application,
|
||||||
ILogger<AccountManager> logger,
|
ILogger<AccountManager> logger,
|
||||||
|
IGraphQLClientFactory graphQLClientFactory,
|
||||||
|
ISpeckleHttp speckleHttp,
|
||||||
IAccountFactory accountFactory,
|
IAccountFactory accountFactory,
|
||||||
IAuthFlow authFlow,
|
|
||||||
ISqLiteJsonCacheManagerFactory sqLiteJsonCacheManagerFactory
|
ISqLiteJsonCacheManagerFactory sqLiteJsonCacheManagerFactory
|
||||||
) : IAccountManager
|
) : IAccountManager
|
||||||
{
|
{
|
||||||
public const string DEFAULT_SERVER_URL = "https://app.speckle.systems";
|
public const string DEFAULT_SERVER_URL = "https://app.speckle.systems";
|
||||||
|
|
||||||
private readonly ISqLiteJsonCacheManager _accountStorage = sqLiteJsonCacheManagerFactory.CreateForUser("Accounts");
|
private readonly ISqLiteJsonCacheManager _accountStorage = sqLiteJsonCacheManagerFactory.CreateForUser("Accounts");
|
||||||
|
private static volatile bool s_isAddingAccount;
|
||||||
|
private readonly ISqLiteJsonCacheManager _accountAddLockStorage = sqLiteJsonCacheManagerFactory.CreateForUser(
|
||||||
|
"AccountAddFlow"
|
||||||
|
);
|
||||||
|
|
||||||
[AutoInterfaceIgnore]
|
[AutoInterfaceIgnore]
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_accountStorage.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>
|
/// <param name="id">The Id of the account to fetch</param>
|
||||||
@@ -41,6 +151,37 @@ public sealed class AccountManager(
|
|||||||
?? throw new SpeckleAccountManagerException($"Account {id} not found");
|
?? 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>
|
/// <summary>
|
||||||
/// Returns all unique accounts matching the serverUrl provided. If an account exists on more than one server,
|
/// 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.
|
/// typically because it has been migrated, then only the upgraded account (and therefore server) are returned.
|
||||||
@@ -104,6 +245,7 @@ public sealed class AccountManager(
|
|||||||
static bool IsInvalid(Account ac) => ac.userInfo == null || ac.serverInfo == null;
|
static bool IsInvalid(Account ac) => ac.userInfo == null || ac.serverInfo == null;
|
||||||
|
|
||||||
var sqlAccounts = _accountStorage.GetAllObjects().Select(x => JsonConvert.DeserializeObject<Account>(x.Json));
|
var sqlAccounts = _accountStorage.GetAllObjects().Select(x => JsonConvert.DeserializeObject<Account>(x.Json));
|
||||||
|
var localAccounts = GetLocalAccounts();
|
||||||
|
|
||||||
foreach (var acc in sqlAccounts)
|
foreach (var acc in sqlAccounts)
|
||||||
{
|
{
|
||||||
@@ -117,55 +259,119 @@ public sealed class AccountManager(
|
|||||||
yield return acc;
|
yield return acc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
foreach (var acc in localAccounts)
|
||||||
/// 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>
|
|
||||||
/// <seealso cref="UpdateAccount"/>
|
|
||||||
/// <param name="cancellationToken"></param>
|
|
||||||
/// <exception cref="AggregateException"></exception>
|
|
||||||
public async Task UpdateAccount(Account account, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
string oldAccountId = account.id;
|
|
||||||
await UpdateAccountInMemory(account, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (oldAccountId != account.id)
|
|
||||||
{
|
{
|
||||||
// ID may have changed, e.g. users email changed, or server url migrated
|
yield return acc;
|
||||||
_accountStorage.DeleteObject(oldAccountId);
|
|
||||||
}
|
}
|
||||||
_accountStorage.UpdateObject(account.id, JsonConvert.SerializeObject(account));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refetches the <paramref name="account"/> information, including <see cref="ServerInfo"/> and <see cref="UserInfo"/>
|
/// Gets the local accounts
|
||||||
///
|
/// These are accounts not handled by Manager and are stored in json format in a local directory
|
||||||
/// Will only mutate <paramref name="account"/> in memory only, and only if successful.
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
private IList<Account> GetLocalAccounts()
|
||||||
|
{
|
||||||
|
var accountsDir = SpecklePathProvider.AccountsFolderPath;
|
||||||
|
if (!Directory.Exists(accountsDir))
|
||||||
|
{
|
||||||
|
return Array.Empty<Account>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <seealso cref="UpdateAccount"/>
|
|
||||||
/// <param name="account"></param>
|
/// <param name="account"></param>
|
||||||
/// <param name="cancellationToken"></param>
|
/// <param name="app"></param>
|
||||||
/// <exception cref="GraphQLHttpRequestException"></exception>
|
private async Task RefreshAndSetAccountToken(Account account, string app)
|
||||||
public async Task UpdateAccountInMemory(Account account, CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
Uri url = account.serverInfo.migration?.movedTo ?? new(account.serverInfo.url);
|
try
|
||||||
|
|
||||||
ActiveUserServerInfoResponse userServerInfo = await accountFactory
|
|
||||||
.GetUserServerInfo(url, account.token, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (userServerInfo.activeUser == null)
|
|
||||||
{
|
{
|
||||||
throw new SpeckleException("GraphQL response indicated that the ActiveUser could not be found");
|
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.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>
|
/// <summary>
|
||||||
@@ -206,103 +412,325 @@ public sealed class AccountManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds an account to local storage by prompting the user to log in via their browser.
|
/// Retrieves the local identifier for the specified account.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <example>
|
/// <param name="account">The account for which to retrieve the local identifier.</param>
|
||||||
/// <code>
|
/// <returns>The local identifier for the specified account in the form of "SERVER_URL?u=USER_ID".</returns>
|
||||||
/// Account account = await AuthenticateAccount(new Uri("https://app.speckle.systems"), TimeSpan.FromMinutes(1));
|
/// <remarks>
|
||||||
/// </code>
|
/// <inheritdoc cref="Account.GetLocalIdentifier"/>
|
||||||
/// </example>
|
/// </remarks>
|
||||||
/// <param name="serverUrl"></param>
|
[Obsolete(Account.LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
|
||||||
/// <param name="timeout">Timeout for user to auth with browser, recommend 1 min timeout</param>
|
public Uri? GetLocalIdentifierForAccount(Account account)
|
||||||
/// <param name="cancellationToken"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<Account> AuthenticateAccount(Uri serverUrl, TimeSpan timeout, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
logger.LogDebug("Starting to add account for {ServerUrl}", serverUrl);
|
var identifier = account.GetLocalIdentifier();
|
||||||
|
|
||||||
TokenExchangeResponse tokenResponse = await authFlow
|
// Validate account is stored locally
|
||||||
.TriggerAuthFlowWithTimeout(serverUrl, AuthApp.ConnectorsV3, timeout, cancellationToken)
|
var searchResult = GetAccountForLocalIdentifier(identifier);
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
return await CreateAndAddAccount(serverUrl, tokenResponse, cancellationToken).ConfigureAwait(false);
|
return searchResult == null ? null : identifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Account> CreateAndAddAccount(
|
public async Task<UserInfo> Validate(Account account)
|
||||||
Uri serverUrl,
|
|
||||||
TokenExchangeResponse tokenResponse,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var account = await accountFactory
|
Uri server = new(account.serverInfo.url);
|
||||||
.CreateAccount(serverUrl, tokenResponse.token, tokenResponse.refreshToken, cancellationToken)
|
return await GetUserInfo(account.token, server).ConfigureAwait(false);
|
||||||
.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>
|
/// <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
|
/// Gets the account that corresponds to the given local identifier.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Obsolete("Unused")]
|
/// <param name="localIdentifier">The local identifier of the account.</param>
|
||||||
public Uri GetDefaultServerUrl()
|
/// <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 customServerUrl = "";
|
var searchResult = GetAccounts()
|
||||||
|
.FirstOrDefault(acc =>
|
||||||
// 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;
|
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}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Uri(DEFAULT_SERVER_URL);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Obsolete("Use Uri overload")]
|
private void UnlockAccountAddFlow()
|
||||||
public IEnumerable<Account> GetAccounts(string serverUrl)
|
|
||||||
{
|
{
|
||||||
return GetAccounts(new Uri(serverUrl));
|
s_isAddingAccount = false;
|
||||||
|
// make sure all old locks are removed
|
||||||
|
foreach (var (id, _) in _accountAddLockStorage.GetAllObjects())
|
||||||
|
{
|
||||||
|
_accountAddLockStorage.DeleteObject(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Obsolete("Use UpdateAccount instead for more control over error handling", true)]
|
/// <summary>
|
||||||
public Task UpdateAccounts(CancellationToken ct = default, string app = "sca") => throw new NotImplementedException();
|
/// 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>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task AddAccount(Uri? server = null)
|
||||||
|
{
|
||||||
|
logger.LogDebug("Starting to add account for {serverUrl}", server);
|
||||||
|
|
||||||
[Obsolete("Use UpdateAccount instead", true)]
|
server = EnsureCorrectServerUrl(server);
|
||||||
public void UpgradeAccount(string id) => throw new NotImplementedException();
|
|
||||||
|
|
||||||
[Obsolete($"Use {nameof(AuthenticateAccount)} instead", true)]
|
// locking for 1 minute
|
||||||
public Task AddAccount(Uri? server = null) => throw new NotImplementedException();
|
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();
|
||||||
|
|
||||||
[Obsolete("Use serverInfo stored on a client instead", true)]
|
try
|
||||||
public Task<ServerInfo> GetServerInfo(Uri server, CancellationToken cancellationToken = default) =>
|
{
|
||||||
throw new NotImplementedException();
|
string accessCode = await GetAccessCode(server, challenge, timeout).ConfigureAwait(false);
|
||||||
|
if (string.IsNullOrEmpty(accessCode))
|
||||||
|
{
|
||||||
|
throw new SpeckleAccountManagerException("Access code is invalid");
|
||||||
|
}
|
||||||
|
|
||||||
[Obsolete("Use userInfo stored on a client instead", true)]
|
var account = await CreateAccount(accessCode, challenge, server).ConfigureAwait(false);
|
||||||
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)]
|
//if the account already exists it will not be added again
|
||||||
public IList<Account> GetLocalAccounts() => throw new NotImplementedException();
|
_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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Obsolete("Use UpdateAccount or UpdateAccountInMemory Instead", true)]
|
private async Task<TokenExchangeResponse> GetToken(string accessCode, string challenge, Uri server)
|
||||||
public IList<Account> Validate() => throw new NotImplementedException();
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TokenExchangeResponse> GetRefreshedToken(string? refreshToken, Uri server, string app = "sca")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = speckleHttp.CreateHttpClient();
|
||||||
|
|
||||||
|
var body = new
|
||||||
|
{
|
||||||
|
appId = app,
|
||||||
|
appSecret = app,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 refreshed token from {server}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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\.@-]", "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
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('=');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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() { }
|
||||||
|
}
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
namespace Speckle.Sdk.Credentials;
|
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 class SpeckleAccountManagerException : SpeckleException
|
||||||
{
|
{
|
||||||
public SpeckleAccountManagerException(string message)
|
public SpeckleAccountManagerException(string message)
|
||||||
@@ -21,3 +10,14 @@ public class SpeckleAccountManagerException : SpeckleException
|
|||||||
|
|
||||||
public SpeckleAccountManagerException() { }
|
public SpeckleAccountManagerException() { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class SpeckleAccountFlowLockedException : SpeckleAccountManagerException
|
||||||
|
{
|
||||||
|
public SpeckleAccountFlowLockedException(string message)
|
||||||
|
: base(message) { }
|
||||||
|
|
||||||
|
public SpeckleAccountFlowLockedException() { }
|
||||||
|
|
||||||
|
public SpeckleAccountFlowLockedException(string message, Exception? innerException)
|
||||||
|
: base(message, innerException) { }
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,19 +6,16 @@ namespace Speckle.Sdk.Credentials;
|
|||||||
internal sealed class ActiveUserServerInfoResponse
|
internal sealed class ActiveUserServerInfoResponse
|
||||||
{
|
{
|
||||||
[property: JsonProperty(Required = Required.AllowNull)]
|
[property: JsonProperty(Required = Required.AllowNull)]
|
||||||
public required UserInfo? activeUser { get; init; }
|
public UserInfo? activeUser { get; init; }
|
||||||
|
|
||||||
[property: JsonProperty(Required = Required.Always)]
|
[property: JsonProperty(Required = Required.Always)]
|
||||||
public required ServerInfo serverInfo { get; init; }
|
public ServerInfo serverInfo { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TokenExchangeResponse
|
internal sealed class TokenExchangeResponse
|
||||||
{
|
{
|
||||||
[JsonRequired]
|
public string token { get; init; }
|
||||||
public required string token { get; init; }
|
public string refreshToken { get; init; }
|
||||||
|
|
||||||
[JsonRequired]
|
|
||||||
public required string refreshToken { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UserInfo
|
public sealed class UserInfo
|
||||||
|
|||||||
@@ -65,12 +65,7 @@ public sealed class DiskStore
|
|||||||
|
|
||||||
await foreach (var item in _channel.ReadAllAsync(_cancellationToken).ConfigureAwait(false))
|
await foreach (var item in _channel.ReadAllAsync(_cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await writer.WriteAsync(item.Id).ConfigureAwait(false);
|
await writer.WriteLineAsync($"{item.Id}\t{item.SpeckleType}\t{item.Json}").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
|
#if NET8_0_OR_GREATER
|
||||||
await writer.FlushAsync(_cancellationToken).ConfigureAwait(false);
|
await writer.FlushAsync(_cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using FluentAssertions;
|
using Speckle.Sdk.Api;
|
||||||
using Speckle.Sdk.Api;
|
|
||||||
using Speckle.Sdk.Api.GraphQL.Resources;
|
using Speckle.Sdk.Api.GraphQL.Resources;
|
||||||
|
|
||||||
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
|
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
|
||||||
@@ -22,19 +21,19 @@ public class WorkspaceResourceTests
|
|||||||
return testUser;
|
return testUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact, Trait("Server", "Internal")]
|
||||||
public async Task TestGetWorkspace()
|
public async Task TestGetWorkspace()
|
||||||
{
|
{
|
||||||
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.Get("non-existent-id"));
|
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.Get("non-existent-id"));
|
||||||
ex.InnerExceptions.Should().HaveCount(1);
|
Assert.Single(ex.InnerExceptions);
|
||||||
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLForbiddenException>();
|
Assert.All(ex.InnerExceptions, item => Assert.IsType<SpeckleGraphQLForbiddenException>(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestGetProjects()
|
public async Task TestGetProjects()
|
||||||
{
|
{
|
||||||
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.GetProjects("non-existent-id"));
|
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.GetProjects("non-existent-id"));
|
||||||
ex.InnerExceptions.Should().HaveCount(1);
|
Assert.Single(ex.InnerExceptions);
|
||||||
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLForbiddenException>();
|
Assert.All(ex.InnerExceptions, item => Assert.IsType<SpeckleGraphQLForbiddenException>(item));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Speckle.Sdk.Tests.Integration;
|
|
||||||
|
|
||||||
[CollectionDefinition(nameof(RequiresSqLiteAccountDb), DisableParallelization = true)]
|
|
||||||
public sealed class RequiresSqLiteAccountDb;
|
|
||||||
|
|
||||||
[CollectionDefinition(nameof(RequiresAuthFlowPort), DisableParallelization = true)]
|
|
||||||
public sealed class RequiresAuthFlowPort;
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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,12 +27,7 @@ namespace Speckle.Sdk.Tests.Integration;
|
|||||||
public static class Fixtures
|
public static class Fixtures
|
||||||
{
|
{
|
||||||
public static readonly ServerInfo Server = new() { url = "http://localhost:3000", name = "Docker Server" };
|
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; }
|
public static IServiceProvider ServiceProvider { get; set; }
|
||||||
|
|
||||||
static Fixtures()
|
static Fixtures()
|
||||||
@@ -100,8 +95,8 @@ public static class Fixtures
|
|||||||
Dictionary<string, string> tokenBody = new()
|
Dictionary<string, string> tokenBody = new()
|
||||||
{
|
{
|
||||||
["accessCode"] = accessCode,
|
["accessCode"] = accessCode,
|
||||||
["appId"] = TestAuthApp.AppId,
|
["appId"] = "spklwebapp",
|
||||||
["appSecret"] = TestAuthApp.AppSecret,
|
["appSecret"] = "spklwebapp",
|
||||||
["challenge"] = "challengingchallenge",
|
["challenge"] = "challengingchallenge",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,11 +109,8 @@ public static class Fixtures
|
|||||||
);
|
);
|
||||||
|
|
||||||
var token = deserialised.NotNull()["token"].NotNull();
|
var token = deserialised.NotNull()["token"].NotNull();
|
||||||
var refreshToken = deserialised.NotNull()["refreshToken"].NotNull();
|
|
||||||
|
|
||||||
return await ServiceProvider
|
return await ServiceProvider.GetRequiredService<IAccountFactory>().CreateAccount(new(Server.url), token);
|
||||||
.GetRequiredService<IAccountFactory>()
|
|
||||||
.CreateAccount(new(Server.url), token, refreshToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Base GenerateSimpleObject()
|
public static Base GenerateSimpleObject()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Moq;
|
|||||||
using Speckle.Newtonsoft.Json;
|
using Speckle.Newtonsoft.Json;
|
||||||
using Speckle.Sdk.Api.GraphQL.Models;
|
using Speckle.Sdk.Api.GraphQL.Models;
|
||||||
using Speckle.Sdk.Credentials;
|
using Speckle.Sdk.Credentials;
|
||||||
|
using Speckle.Sdk.Helpers;
|
||||||
using Speckle.Sdk.SQLite;
|
using Speckle.Sdk.SQLite;
|
||||||
using Speckle.Sdk.Testing;
|
using Speckle.Sdk.Testing;
|
||||||
|
|
||||||
@@ -27,11 +28,14 @@ public sealed class AccountManagerTests : MoqTest
|
|||||||
) => throw new NotImplementedException();
|
) => throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly Mock<ISpeckleApplication> _mockApplication;
|
||||||
private readonly Mock<ILogger<AccountManager>> _mockLogger;
|
private readonly Mock<ILogger<AccountManager>> _mockLogger;
|
||||||
|
private readonly Mock<IGraphQLClientFactory> _mockGraphQLClientFactory;
|
||||||
|
private readonly Mock<ISpeckleHttp> _mockSpeckleHttp;
|
||||||
private readonly IAccountFactory _mockAccountFactory;
|
private readonly IAccountFactory _mockAccountFactory;
|
||||||
private readonly Mock<ISqLiteJsonCacheManagerFactory> _mockSqLiteJsonCacheManagerFactory;
|
private readonly Mock<ISqLiteJsonCacheManagerFactory> _mockSqLiteJsonCacheManagerFactory;
|
||||||
private readonly Mock<ISqLiteJsonCacheManager> _mockAccountStorage;
|
private readonly Mock<ISqLiteJsonCacheManager> _mockAccountStorage;
|
||||||
private readonly Mock<IAuthFlow> _mockAuthFlow;
|
private readonly Mock<ISqLiteJsonCacheManager> _mockAccountAddLockStorage;
|
||||||
|
|
||||||
#pragma warning disable CA2213
|
#pragma warning disable CA2213
|
||||||
private readonly AccountManager _accountManager;
|
private readonly AccountManager _accountManager;
|
||||||
@@ -39,19 +43,27 @@ public sealed class AccountManagerTests : MoqTest
|
|||||||
|
|
||||||
public AccountManagerTests()
|
public AccountManagerTests()
|
||||||
{
|
{
|
||||||
|
_mockApplication = Create<ISpeckleApplication>();
|
||||||
_mockLogger = Create<ILogger<AccountManager>>(MockBehavior.Loose);
|
_mockLogger = Create<ILogger<AccountManager>>(MockBehavior.Loose);
|
||||||
|
_mockGraphQLClientFactory = Create<IGraphQLClientFactory>();
|
||||||
|
_mockSpeckleHttp = Create<ISpeckleHttp>();
|
||||||
_mockAccountFactory = new TestAccountFactory();
|
_mockAccountFactory = new TestAccountFactory();
|
||||||
_mockSqLiteJsonCacheManagerFactory = Create<ISqLiteJsonCacheManagerFactory>();
|
_mockSqLiteJsonCacheManagerFactory = Create<ISqLiteJsonCacheManagerFactory>();
|
||||||
_mockAuthFlow = Create<IAuthFlow>();
|
|
||||||
|
|
||||||
_mockAccountStorage = Create<ISqLiteJsonCacheManager>();
|
_mockAccountStorage = Create<ISqLiteJsonCacheManager>();
|
||||||
|
_mockAccountAddLockStorage = Create<ISqLiteJsonCacheManager>();
|
||||||
|
|
||||||
_mockSqLiteJsonCacheManagerFactory.Setup(f => f.CreateForUser("Accounts")).Returns(_mockAccountStorage.Object);
|
_mockSqLiteJsonCacheManagerFactory.Setup(f => f.CreateForUser("Accounts")).Returns(_mockAccountStorage.Object);
|
||||||
|
_mockSqLiteJsonCacheManagerFactory
|
||||||
|
.Setup(f => f.CreateForUser("AccountAddFlow"))
|
||||||
|
.Returns(_mockAccountAddLockStorage.Object);
|
||||||
|
|
||||||
_accountManager = new AccountManager(
|
_accountManager = new AccountManager(
|
||||||
|
_mockApplication.Object,
|
||||||
_mockLogger.Object,
|
_mockLogger.Object,
|
||||||
|
_mockGraphQLClientFactory.Object,
|
||||||
|
_mockSpeckleHttp.Object,
|
||||||
_mockAccountFactory,
|
_mockAccountFactory,
|
||||||
_mockAuthFlow.Object,
|
|
||||||
_mockSqLiteJsonCacheManagerFactory.Object
|
_mockSqLiteJsonCacheManagerFactory.Object
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -318,6 +330,71 @@ 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
|
// Helper method to create a test account
|
||||||
private static Account CreateTestAccount(string id)
|
private static Account CreateTestAccount(string id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public class CredentialInfrastructure : IDisposable
|
|||||||
{
|
{
|
||||||
Fixtures.UpdateOrSaveAccount(s_testAccount1);
|
Fixtures.UpdateOrSaveAccount(s_testAccount1);
|
||||||
Fixtures.UpdateOrSaveAccount(s_testAccount2);
|
Fixtures.UpdateOrSaveAccount(s_testAccount2);
|
||||||
Fixtures.UpdateOrSaveAccount(s_testAccount3);
|
Fixtures.SaveLocalAccount(s_testAccount3);
|
||||||
|
|
||||||
var serviceProvider = TestServiceSetup.GetServiceProvider();
|
var serviceProvider = TestServiceSetup.GetServiceProvider();
|
||||||
_accountManager = serviceProvider.GetRequiredService<IAccountManager>();
|
_accountManager = serviceProvider.GetRequiredService<IAccountManager>();
|
||||||
@@ -60,6 +60,7 @@ public class CredentialInfrastructure : IDisposable
|
|||||||
Fixtures.DeleteLocalAccount(s_testAccount1.id);
|
Fixtures.DeleteLocalAccount(s_testAccount1.id);
|
||||||
Fixtures.DeleteLocalAccount(s_testAccount2.id);
|
Fixtures.DeleteLocalAccount(s_testAccount2.id);
|
||||||
Fixtures.DeleteLocalAccount(s_testAccount3.id);
|
Fixtures.DeleteLocalAccount(s_testAccount3.id);
|
||||||
|
Fixtures.DeleteLocalAccountFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -92,7 +93,7 @@ public class CredentialInfrastructure : IDisposable
|
|||||||
{
|
{
|
||||||
var accs = _accountManager.GetAccounts(target.serverInfo.url).ToList();
|
var accs = _accountManager.GetAccounts(target.serverInfo.url).ToList();
|
||||||
|
|
||||||
accs.Should().HaveCount(1);
|
accs.Count.Should().Be(1);
|
||||||
|
|
||||||
var acc = accs[0];
|
var acc = accs[0];
|
||||||
|
|
||||||
@@ -102,4 +103,24 @@ public class CredentialInfrastructure : IDisposable
|
|||||||
acc.refreshToken.Should().Be(target.refreshToken);
|
acc.refreshToken.Should().Be(target.refreshToken);
|
||||||
acc.token.Should().Be(target.token);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Speckle.Sdk.Common;
|
using Speckle.Sdk.Common;
|
||||||
using Speckle.Sdk.Credentials;
|
using Speckle.Sdk.Credentials;
|
||||||
|
using Speckle.Sdk.Logging;
|
||||||
using Speckle.Sdk.Transports;
|
using Speckle.Sdk.Transports;
|
||||||
|
|
||||||
namespace Speckle.Sdk.Tests.Unit;
|
namespace Speckle.Sdk.Tests.Unit;
|
||||||
@@ -9,6 +10,11 @@ public abstract class Fixtures
|
|||||||
{
|
{
|
||||||
private static readonly SQLiteTransport s_accountStorage = new(scope: "Accounts");
|
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)
|
public static void UpdateOrSaveAccount(Account account)
|
||||||
{
|
{
|
||||||
DeleteLocalAccount(account.id.NotNull());
|
DeleteLocalAccount(account.id.NotNull());
|
||||||
@@ -16,5 +22,13 @@ public abstract class Fixtures
|
|||||||
s_accountStorage.SaveObjectSync(account.id, serializedObject);
|
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 DeleteLocalAccount(string id) => s_accountStorage.DeleteObject(id);
|
||||||
|
|
||||||
|
public static void DeleteLocalAccountFile() => File.Delete(s_accountPath);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user