Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c517dead03 | |||
| 2b61ab7d2e | |||
| 4b319499c3 | |||
| d4055c6ff1 |
@@ -1,3 +1,5 @@
|
||||
using Speckle.Newtonsoft.Json;
|
||||
|
||||
namespace Speckle.Sdk.Api.GraphQL.Models;
|
||||
|
||||
public class LimitedWorkspace
|
||||
@@ -6,8 +8,12 @@ public class LimitedWorkspace
|
||||
public string name { get; init; }
|
||||
public string? role { get; init; }
|
||||
public string slug { get; init; }
|
||||
public string? logo { get; init; }
|
||||
public string? logoUri { get; init; }
|
||||
public string? description { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
[Obsolete($"Deprecated, use {nameof(logoUri)} instead", true)]
|
||||
public string? logo { get; init; }
|
||||
}
|
||||
|
||||
public class Workspace : LimitedWorkspace
|
||||
@@ -16,9 +22,13 @@ public class Workspace : LimitedWorkspace
|
||||
public DateTime updatedAt { get; init; }
|
||||
public bool readOnly { 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; }
|
||||
}
|
||||
|
||||
[Obsolete("Workspaces no longer have creation state, is always created true")]
|
||||
public sealed class WorkspaceCreationState
|
||||
{
|
||||
public bool completed { get; init; }
|
||||
|
||||
@@ -264,15 +264,11 @@ public sealed class ActiveUserResource
|
||||
name
|
||||
role
|
||||
slug
|
||||
logo
|
||||
logoUrl
|
||||
createdAt
|
||||
updatedAt
|
||||
readOnly
|
||||
description
|
||||
creationState
|
||||
{
|
||||
completed
|
||||
}
|
||||
permissions {
|
||||
canCreateProject {
|
||||
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>
|
||||
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
|
||||
/// <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
|
||||
const string QUERY = """
|
||||
@@ -328,7 +324,6 @@ public sealed class ActiveUserResource
|
||||
name
|
||||
role
|
||||
slug
|
||||
logo
|
||||
description
|
||||
}
|
||||
}
|
||||
@@ -349,6 +344,47 @@ public sealed class ActiveUserResource
|
||||
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="cursor">Optional cursor for pagination</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="limit">Max number of users to fetch</param>
|
||||
/// <param name="cursor">Optional cursor for pagination</param>
|
||||
/// <param name="archived"></param>
|
||||
/// <param name="emailOnly"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
@@ -61,26 +60,25 @@ public sealed class OtherUserResource
|
||||
string query,
|
||||
int limit = ServerLimits.DEFAULT_PAGINATION_REQUEST,
|
||||
string? cursor = null,
|
||||
bool archived = false,
|
||||
bool emailOnly = false,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
//language=graphql
|
||||
const string QUERY = """
|
||||
query UserSearch($query: String!, $limit: Int!, $cursor: String, $archived: Boolean, $emailOnly: Boolean) {
|
||||
data:userSearch(query: $query, limit: $limit, cursor: $cursor, archived: $archived, emailOnly: $emailOnly) {
|
||||
query Users($input: UsersRetrievalInput!) {
|
||||
data:users(input: $input) {
|
||||
cursor
|
||||
items {
|
||||
id
|
||||
name
|
||||
bio
|
||||
company
|
||||
avatar
|
||||
verified
|
||||
role
|
||||
}
|
||||
}
|
||||
id
|
||||
name
|
||||
bio
|
||||
company
|
||||
avatar
|
||||
verified
|
||||
role
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -89,11 +87,13 @@ public sealed class OtherUserResource
|
||||
Query = QUERY,
|
||||
Variables = new
|
||||
{
|
||||
query,
|
||||
limit,
|
||||
cursor,
|
||||
archived,
|
||||
emailOnly,
|
||||
input = new
|
||||
{
|
||||
query,
|
||||
limit,
|
||||
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>
|
||||
/// <remarks><inheritdoc cref="CreateUserProjectsUpdatedSubscription"/></remarks>
|
||||
/// <inheritdoc cref="ISpeckleGraphQLClient.SubscribeTo{T}"/>
|
||||
[Obsolete("Comments are now issues, and we've not update SDKs with the new subs")]
|
||||
public Subscription<ProjectCommentsUpdatedMessage> CreateProjectCommentsUpdatedSubscription(
|
||||
ViewerUpdateTrackingTarget target
|
||||
)
|
||||
|
||||
@@ -28,15 +28,11 @@ public sealed class WorkspaceResource
|
||||
name
|
||||
role
|
||||
slug
|
||||
logo
|
||||
logoUrl
|
||||
createdAt
|
||||
updatedAt
|
||||
readOnly
|
||||
description
|
||||
creationState
|
||||
{
|
||||
completed
|
||||
}
|
||||
permissions {
|
||||
canCreateProject {
|
||||
authorized
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Speckle.Sdk.Api.GraphQL.Models;
|
||||
using Speckle.Sdk.Common;
|
||||
|
||||
namespace Speckle.Sdk.Credentials;
|
||||
|
||||
[ClassInterface(ClassInterfaceType.AutoDual)]
|
||||
[ComVisible(true)]
|
||||
public class Account : IEquatable<Account>
|
||||
{
|
||||
private string _id;
|
||||
@@ -34,8 +37,6 @@ public class Account : IEquatable<Account>
|
||||
public string? refreshToken { get; set; }
|
||||
|
||||
public bool isDefault { get; set; }
|
||||
|
||||
[Obsolete("Not used in v3")]
|
||||
public bool isOnline { get; set; } = true;
|
||||
|
||||
public ServerInfo serverInfo { get; set; }
|
||||
@@ -100,4 +101,33 @@ public class Account : IEquatable<Account>
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
internal const string LOCAL_IDENTIFIER_DEPRECATION_MESSAGE = "Local identifiers no longer nesseary";
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the local identifier for the current user.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Returns a <see cref="Uri"/> object representing the local identifier for the current user.
|
||||
/// The local identifier is created by appending the user ID as a query parameter to the server URL.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Notice that the generated Uri is not intended to be used as a functioning Uri, but rather as a
|
||||
/// unique identifier for a specific account in a local environment. The format of the Uri, containing a query parameter with the user ID,
|
||||
/// serves this specific purpose. Therefore, it should not be used for forming network requests or
|
||||
/// expecting it to lead to an actual webpage. The primary intent of this Uri is for unique identification in a Uri format.
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// This sample shows how to call the GetLocalIdentifier method.
|
||||
/// <code>
|
||||
/// Uri localIdentifier = GetLocalIdentifier();
|
||||
/// Console.WriteLine(localIdentifier);
|
||||
/// </code>
|
||||
/// For a fictional `User ID: 123` and `Server: https://speckle.xyz`, the output might look like this:
|
||||
/// <code>
|
||||
/// https://speckle.xyz?id=123
|
||||
/// </code>
|
||||
/// </example>
|
||||
[Obsolete(LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
|
||||
internal Uri GetLocalIdentifier() => new($"{serverInfo.url}?id={userInfo.id}");
|
||||
}
|
||||
|
||||
@@ -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 Microsoft.Extensions.Logging;
|
||||
using Speckle.InterfaceGenerator;
|
||||
using Speckle.Newtonsoft.Json;
|
||||
using Speckle.Sdk.Api.GraphQL;
|
||||
using Speckle.Sdk.Api.GraphQL.Models;
|
||||
using Speckle.Sdk.Api.GraphQL.Models.Responses;
|
||||
using Speckle.Sdk.Common;
|
||||
using Speckle.Sdk.Helpers;
|
||||
using Speckle.Sdk.Logging;
|
||||
using Speckle.Sdk.SQLite;
|
||||
using Stream = System.IO.Stream;
|
||||
|
||||
namespace Speckle.Sdk.Credentials;
|
||||
|
||||
public partial interface IAccountManager : IDisposable;
|
||||
|
||||
/// <summary>
|
||||
/// Manages <see cref="Account"/> data in the local sqlite account store
|
||||
/// Manage accounts locally for desktop applications.
|
||||
/// </summary>
|
||||
[GenerateAutoInterface]
|
||||
public sealed class AccountManager(
|
||||
ISpeckleApplication application,
|
||||
ILogger<AccountManager> logger,
|
||||
IGraphQLClientFactory graphQLClientFactory,
|
||||
ISpeckleHttp speckleHttp,
|
||||
IAccountFactory accountFactory,
|
||||
IAuthFlow authFlow,
|
||||
ISqLiteJsonCacheManagerFactory sqLiteJsonCacheManagerFactory
|
||||
) : IAccountManager
|
||||
{
|
||||
public const string DEFAULT_SERVER_URL = "https://app.speckle.systems";
|
||||
|
||||
private readonly ISqLiteJsonCacheManager _accountStorage = sqLiteJsonCacheManagerFactory.CreateForUser("Accounts");
|
||||
private static volatile bool s_isAddingAccount;
|
||||
private readonly ISqLiteJsonCacheManager _accountAddLockStorage = sqLiteJsonCacheManagerFactory.CreateForUser(
|
||||
"AccountAddFlow"
|
||||
);
|
||||
|
||||
[AutoInterfaceIgnore]
|
||||
public void Dispose()
|
||||
{
|
||||
_accountStorage.Dispose();
|
||||
_accountAddLockStorage.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the basic information about a server.
|
||||
/// </summary>
|
||||
/// <param name="server">Server Information</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (received a non-successful response code)</exception>
|
||||
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
|
||||
public async Task<ServerInfo> GetServerInfo(Uri server, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var gqlClient = graphQLClientFactory.CreateGraphQLClient(server, null);
|
||||
|
||||
//lang=graphql
|
||||
const string QUERY_STRING = "query { serverInfo { name company migration { movedFrom movedTo } } }";
|
||||
|
||||
var request = new GraphQLRequest { Query = QUERY_STRING };
|
||||
|
||||
var response = await gqlClient.SendQueryAsync<ServerInfoResponse>(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureGraphQLSuccess();
|
||||
|
||||
ServerInfo serverInfo = response.Data.serverInfo;
|
||||
serverInfo.url = server.ToString().TrimEnd('/');
|
||||
|
||||
return response.Data.serverInfo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets basic user information given a token and a server.
|
||||
/// </summary>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="server">Server URL</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="GraphQLHttpRequestException">Request failed on the HTTP layer (received a non-successful response code)</exception>
|
||||
/// <exception cref="AggregateException"><inheritdoc cref="GraphQLErrorHandler.EnsureGraphQLSuccess(IGraphQLResponse)"/></exception>
|
||||
public async Task<UserInfo> GetUserInfo(string token, Uri server, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var gqlClient = graphQLClientFactory.CreateGraphQLClient(server, token);
|
||||
|
||||
//language=graphql
|
||||
const string QUERY = """
|
||||
query {
|
||||
data:activeUser {
|
||||
name
|
||||
email
|
||||
id
|
||||
company
|
||||
}
|
||||
}
|
||||
""";
|
||||
var request = new GraphQLRequest { Query = QUERY };
|
||||
|
||||
var response = await gqlClient
|
||||
.SendQueryAsync<RequiredResponse<UserInfo>>(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
response.EnsureGraphQLSuccess();
|
||||
|
||||
return response.Data.data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Default Server URL for authentication, can be overridden by placing a file with the alternatrive url in the Speckle folder or with an ENV_VAR
|
||||
/// </summary>
|
||||
public Uri GetDefaultServerUrl()
|
||||
{
|
||||
var customServerUrl = "";
|
||||
|
||||
// first mechanism, check for local file
|
||||
var customServerFile = Path.Combine(SpecklePathProvider.UserSpeckleFolderPath, "server");
|
||||
if (File.Exists(customServerFile))
|
||||
{
|
||||
customServerUrl = File.ReadAllText(customServerFile);
|
||||
}
|
||||
|
||||
// second mechanism, check ENV VAR
|
||||
var customServerEnvVar = Environment.GetEnvironmentVariable("SPECKLE_SERVER");
|
||||
if (!string.IsNullOrEmpty(customServerEnvVar))
|
||||
{
|
||||
customServerUrl = customServerEnvVar;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(customServerUrl))
|
||||
{
|
||||
if (Uri.TryCreate(customServerUrl, UriKind.Absolute, out Uri? url))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
return new Uri(DEFAULT_SERVER_URL);
|
||||
}
|
||||
|
||||
/// <param name="id">The Id of the account to fetch</param>
|
||||
@@ -41,6 +151,37 @@ public sealed class AccountManager(
|
||||
?? throw new SpeckleAccountManagerException($"Account {id} not found");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upgrades an account from the account.serverInfo.movedFrom account to the account.serverInfo.movedTo account
|
||||
/// </summary>
|
||||
/// <param name="id">Id of the account to upgrade</param>
|
||||
public void UpgradeAccount(string id)
|
||||
{
|
||||
Account account = GetAccount(id);
|
||||
|
||||
if (account.serverInfo.migration?.movedTo is not Uri upgradeUri)
|
||||
{
|
||||
throw new SpeckleAccountManagerException(
|
||||
$"Server with url {account.serverInfo.url} does not have information about the upgraded server"
|
||||
);
|
||||
}
|
||||
|
||||
account.serverInfo.migration.movedTo = null;
|
||||
account.serverInfo.migration.movedFrom = new Uri(account.serverInfo.url);
|
||||
account.serverInfo.url = upgradeUri.ToString().TrimEnd('/');
|
||||
|
||||
// setting the id to null will force it to be recreated
|
||||
account.id = null!; //TODO this is gross so remove when id is nullable
|
||||
|
||||
RemoveAccount(id);
|
||||
_accountStorage.UpdateObject(account.id.NotNull(), JsonConvert.SerializeObject(account));
|
||||
}
|
||||
|
||||
public IEnumerable<Account> GetAccounts(string serverUrl)
|
||||
{
|
||||
return GetAccounts(new Uri(serverUrl));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all unique accounts matching the serverUrl provided. If an account exists on more than one server,
|
||||
/// typically because it has been migrated, then only the upgraded account (and therefore server) are returned.
|
||||
@@ -104,6 +245,7 @@ public sealed class AccountManager(
|
||||
static bool IsInvalid(Account ac) => ac.userInfo == null || ac.serverInfo == null;
|
||||
|
||||
var sqlAccounts = _accountStorage.GetAllObjects().Select(x => JsonConvert.DeserializeObject<Account>(x.Json));
|
||||
var localAccounts = GetLocalAccounts();
|
||||
|
||||
foreach (var acc in sqlAccounts)
|
||||
{
|
||||
@@ -117,55 +259,119 @@ public sealed class AccountManager(
|
||||
yield return acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
foreach (var acc in localAccounts)
|
||||
{
|
||||
// ID may have changed, e.g. users email changed, or server url migrated
|
||||
_accountStorage.DeleteObject(oldAccountId);
|
||||
yield return acc;
|
||||
}
|
||||
_accountStorage.UpdateObject(account.id, JsonConvert.SerializeObject(account));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refetches the <paramref name="account"/> information, including <see cref="ServerInfo"/> and <see cref="UserInfo"/>
|
||||
///
|
||||
/// Will only mutate <paramref name="account"/> in memory only, and only if successful.
|
||||
/// Gets the local accounts
|
||||
/// These are accounts not handled by Manager and are stored in json format in a local directory
|
||||
/// </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>
|
||||
/// <seealso cref="UpdateAccount"/>
|
||||
/// <param name="account"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <exception cref="GraphQLHttpRequestException"></exception>
|
||||
public async Task UpdateAccountInMemory(Account account, CancellationToken cancellationToken = default)
|
||||
/// <param name="app"></param>
|
||||
private async Task RefreshAndSetAccountToken(Account account, string app)
|
||||
{
|
||||
Uri url = account.serverInfo.migration?.movedTo ?? new(account.serverInfo.url);
|
||||
|
||||
ActiveUserServerInfoResponse userServerInfo = await accountFactory
|
||||
.GetUserServerInfo(url, account.token, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (userServerInfo.activeUser == null)
|
||||
try
|
||||
{
|
||||
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>
|
||||
@@ -206,103 +412,325 @@ public sealed class AccountManager(
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// Account account = await AuthenticateAccount(new Uri("https://app.speckle.systems"), TimeSpan.FromMinutes(1));
|
||||
/// </code>
|
||||
/// </example>
|
||||
/// <param name="serverUrl"></param>
|
||||
/// <param name="timeout">Timeout for user to auth with browser, recommend 1 min timeout</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Account> AuthenticateAccount(Uri serverUrl, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
/// <param name="account">The account for which to retrieve the local identifier.</param>
|
||||
/// <returns>The local identifier for the specified account in the form of "SERVER_URL?u=USER_ID".</returns>
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="Account.GetLocalIdentifier"/>
|
||||
/// </remarks>
|
||||
[Obsolete(Account.LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
|
||||
public Uri? GetLocalIdentifierForAccount(Account account)
|
||||
{
|
||||
logger.LogDebug("Starting to add account for {ServerUrl}", serverUrl);
|
||||
var identifier = account.GetLocalIdentifier();
|
||||
|
||||
TokenExchangeResponse tokenResponse = await authFlow
|
||||
.TriggerAuthFlowWithTimeout(serverUrl, AuthApp.ConnectorsV3, timeout, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
// Validate account is stored locally
|
||||
var searchResult = GetAccountForLocalIdentifier(identifier);
|
||||
|
||||
return await CreateAndAddAccount(serverUrl, tokenResponse, cancellationToken).ConfigureAwait(false);
|
||||
return searchResult == null ? null : identifier;
|
||||
}
|
||||
|
||||
public async Task<Account> CreateAndAddAccount(
|
||||
Uri serverUrl,
|
||||
TokenExchangeResponse tokenResponse,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
public async Task<UserInfo> Validate(Account account)
|
||||
{
|
||||
var account = await accountFactory
|
||||
.CreateAccount(serverUrl, tokenResponse.token, tokenResponse.refreshToken, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
account.isDefault = !GetAccounts().Any();
|
||||
|
||||
_accountStorage.SaveObject(account.id, JsonConvert.SerializeObject(account));
|
||||
logger.LogInformation("Successfully authenticated account {AccountId} for {ServerUrl}", account.id, serverUrl);
|
||||
return account;
|
||||
Uri server = new(account.serverInfo.url);
|
||||
return await GetUserInfo(account.token, server).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
[Obsolete("Unused")]
|
||||
public Uri GetDefaultServerUrl()
|
||||
/// <param name="localIdentifier">The local identifier of the account.</param>
|
||||
/// <returns>The account that matches the local identifier, or null if no match is found.</returns>
|
||||
[Obsolete(Account.LOCAL_IDENTIFIER_DEPRECATION_MESSAGE)]
|
||||
public Account? GetAccountForLocalIdentifier(Uri localIdentifier)
|
||||
{
|
||||
var 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))
|
||||
var searchResult = GetAccounts()
|
||||
.FirstOrDefault(acc =>
|
||||
{
|
||||
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")]
|
||||
public IEnumerable<Account> GetAccounts(string serverUrl)
|
||||
private void UnlockAccountAddFlow()
|
||||
{
|
||||
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)]
|
||||
public Task UpdateAccounts(CancellationToken ct = default, string app = "sca") => throw new NotImplementedException();
|
||||
/// <summary>
|
||||
/// Adds an account by propting the user to log in via a web flow
|
||||
/// </summary>
|
||||
/// <param name="server">Server to use to add the account, if not provied the default Server will be used</param>
|
||||
/// <returns></returns>
|
||||
public async Task AddAccount(Uri? server = null)
|
||||
{
|
||||
logger.LogDebug("Starting to add account for {serverUrl}", server);
|
||||
|
||||
[Obsolete("Use UpdateAccount instead", true)]
|
||||
public void UpgradeAccount(string id) => throw new NotImplementedException();
|
||||
server = EnsureCorrectServerUrl(server);
|
||||
|
||||
[Obsolete($"Use {nameof(AuthenticateAccount)} instead", true)]
|
||||
public Task AddAccount(Uri? server = null) => throw new NotImplementedException();
|
||||
// locking for 1 minute
|
||||
var timeout = TimeSpan.FromMinutes(1);
|
||||
// this is not part of the try finally block
|
||||
// we do not want to clean up the existing locks
|
||||
TryLockAccountAddFlow(timeout);
|
||||
var challenge = GenerateChallenge();
|
||||
|
||||
[Obsolete("Use serverInfo stored on a client instead", true)]
|
||||
public Task<ServerInfo> GetServerInfo(Uri server, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
try
|
||||
{
|
||||
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)]
|
||||
public Task<UserInfo> GetUserInfo(string token, Uri server, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
var account = await CreateAccount(accessCode, challenge, server).ConfigureAwait(false);
|
||||
|
||||
[Obsolete("Accounts must now be stored in sqlite db, no more json workaround", true)]
|
||||
public IList<Account> GetLocalAccounts() => throw new NotImplementedException();
|
||||
//if the account already exists it will not be added again
|
||||
_accountStorage.SaveObject(account.id, JsonConvert.SerializeObject(account));
|
||||
logger.LogDebug("Finished adding account {accountId} for {serverUrl}", account.id, server);
|
||||
}
|
||||
catch (SpeckleAccountManagerException ex)
|
||||
{
|
||||
logger.LogCritical(ex, "Failed to add account: {exceptionMessage}", ex.Message);
|
||||
// rethrowing any known errors
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (!ex.IsFatal())
|
||||
{
|
||||
logger.LogCritical(ex, "Failed to add account: {exceptionMessage}", ex.Message);
|
||||
throw new SpeckleAccountManagerException($"Failed to add account: {ex.Message}", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnlockAccountAddFlow();
|
||||
}
|
||||
}
|
||||
|
||||
[Obsolete("Use UpdateAccount or UpdateAccountInMemory Instead", true)]
|
||||
public IList<Account> Validate() => throw new NotImplementedException();
|
||||
private async Task<TokenExchangeResponse> GetToken(string accessCode, string challenge, Uri server)
|
||||
{
|
||||
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;
|
||||
|
||||
public sealed class AuthFlowException : SpeckleException
|
||||
{
|
||||
public AuthFlowException(string? message, Exception? innerException)
|
||||
: base(message, innerException) { }
|
||||
|
||||
public AuthFlowException(string? message)
|
||||
: base(message) { }
|
||||
|
||||
public AuthFlowException() { }
|
||||
}
|
||||
|
||||
public class SpeckleAccountManagerException : SpeckleException
|
||||
{
|
||||
public SpeckleAccountManagerException(string message)
|
||||
@@ -21,3 +10,14 @@ public class SpeckleAccountManagerException : SpeckleException
|
||||
|
||||
public SpeckleAccountManagerException() { }
|
||||
}
|
||||
|
||||
public class SpeckleAccountFlowLockedException : SpeckleAccountManagerException
|
||||
{
|
||||
public SpeckleAccountFlowLockedException(string message)
|
||||
: base(message) { }
|
||||
|
||||
public SpeckleAccountFlowLockedException() { }
|
||||
|
||||
public SpeckleAccountFlowLockedException(string message, Exception? innerException)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
|
||||
@@ -6,19 +6,16 @@ namespace Speckle.Sdk.Credentials;
|
||||
internal sealed class ActiveUserServerInfoResponse
|
||||
{
|
||||
[property: JsonProperty(Required = Required.AllowNull)]
|
||||
public required UserInfo? activeUser { get; init; }
|
||||
public UserInfo? activeUser { get; init; }
|
||||
|
||||
[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 required string token { get; init; }
|
||||
|
||||
[JsonRequired]
|
||||
public required string refreshToken { get; init; }
|
||||
public string token { get; init; }
|
||||
public string refreshToken { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UserInfo
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using FluentAssertions;
|
||||
using Speckle.Sdk.Api;
|
||||
using Speckle.Sdk.Api;
|
||||
using Speckle.Sdk.Api.GraphQL.Resources;
|
||||
|
||||
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
|
||||
@@ -22,19 +21,19 @@ public class WorkspaceResourceTests
|
||||
return testUser;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact, Trait("Server", "Internal")]
|
||||
public async Task TestGetWorkspace()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.Get("non-existent-id"));
|
||||
ex.InnerExceptions.Should().HaveCount(1);
|
||||
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLForbiddenException>();
|
||||
Assert.Single(ex.InnerExceptions);
|
||||
Assert.All(ex.InnerExceptions, item => Assert.IsType<SpeckleGraphQLForbiddenException>(item));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestGetProjects()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<AggregateException>(async () => _ = await Sut.GetProjects("non-existent-id"));
|
||||
ex.InnerExceptions.Should().HaveCount(1);
|
||||
ex.InnerExceptions.Should().AllBeOfType<SpeckleGraphQLForbiddenException>();
|
||||
Assert.Single(ex.InnerExceptions);
|
||||
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 readonly ServerInfo Server = new() { url = "http://localhost:3000", name = "Docker Server" };
|
||||
public static readonly AuthApp TestAuthApp = new()
|
||||
{
|
||||
AppId = "spklwebapp",
|
||||
AppSecret = "spklwebapp",
|
||||
CallbackUrl = new Uri("invaid://localhost"),
|
||||
};
|
||||
|
||||
public static IServiceProvider ServiceProvider { get; set; }
|
||||
|
||||
static Fixtures()
|
||||
@@ -100,8 +95,8 @@ public static class Fixtures
|
||||
Dictionary<string, string> tokenBody = new()
|
||||
{
|
||||
["accessCode"] = accessCode,
|
||||
["appId"] = TestAuthApp.AppId,
|
||||
["appSecret"] = TestAuthApp.AppSecret,
|
||||
["appId"] = "spklwebapp",
|
||||
["appSecret"] = "spklwebapp",
|
||||
["challenge"] = "challengingchallenge",
|
||||
};
|
||||
|
||||
@@ -114,11 +109,8 @@ public static class Fixtures
|
||||
);
|
||||
|
||||
var token = deserialised.NotNull()["token"].NotNull();
|
||||
var refreshToken = deserialised.NotNull()["refreshToken"].NotNull();
|
||||
|
||||
return await ServiceProvider
|
||||
.GetRequiredService<IAccountFactory>()
|
||||
.CreateAccount(new(Server.url), token, refreshToken);
|
||||
return await ServiceProvider.GetRequiredService<IAccountFactory>().CreateAccount(new(Server.url), token);
|
||||
}
|
||||
|
||||
public static Base GenerateSimpleObject()
|
||||
|
||||
@@ -3,6 +3,7 @@ using Moq;
|
||||
using Speckle.Newtonsoft.Json;
|
||||
using Speckle.Sdk.Api.GraphQL.Models;
|
||||
using Speckle.Sdk.Credentials;
|
||||
using Speckle.Sdk.Helpers;
|
||||
using Speckle.Sdk.SQLite;
|
||||
using Speckle.Sdk.Testing;
|
||||
|
||||
@@ -27,11 +28,14 @@ public sealed class AccountManagerTests : MoqTest
|
||||
) => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private readonly Mock<ISpeckleApplication> _mockApplication;
|
||||
private readonly Mock<ILogger<AccountManager>> _mockLogger;
|
||||
private readonly Mock<IGraphQLClientFactory> _mockGraphQLClientFactory;
|
||||
private readonly Mock<ISpeckleHttp> _mockSpeckleHttp;
|
||||
private readonly IAccountFactory _mockAccountFactory;
|
||||
private readonly Mock<ISqLiteJsonCacheManagerFactory> _mockSqLiteJsonCacheManagerFactory;
|
||||
private readonly Mock<ISqLiteJsonCacheManager> _mockAccountStorage;
|
||||
private readonly Mock<IAuthFlow> _mockAuthFlow;
|
||||
private readonly Mock<ISqLiteJsonCacheManager> _mockAccountAddLockStorage;
|
||||
|
||||
#pragma warning disable CA2213
|
||||
private readonly AccountManager _accountManager;
|
||||
@@ -39,19 +43,27 @@ public sealed class AccountManagerTests : MoqTest
|
||||
|
||||
public AccountManagerTests()
|
||||
{
|
||||
_mockApplication = Create<ISpeckleApplication>();
|
||||
_mockLogger = Create<ILogger<AccountManager>>(MockBehavior.Loose);
|
||||
_mockGraphQLClientFactory = Create<IGraphQLClientFactory>();
|
||||
_mockSpeckleHttp = Create<ISpeckleHttp>();
|
||||
_mockAccountFactory = new TestAccountFactory();
|
||||
_mockSqLiteJsonCacheManagerFactory = Create<ISqLiteJsonCacheManagerFactory>();
|
||||
_mockAuthFlow = Create<IAuthFlow>();
|
||||
|
||||
_mockAccountStorage = Create<ISqLiteJsonCacheManager>();
|
||||
_mockAccountAddLockStorage = Create<ISqLiteJsonCacheManager>();
|
||||
|
||||
_mockSqLiteJsonCacheManagerFactory.Setup(f => f.CreateForUser("Accounts")).Returns(_mockAccountStorage.Object);
|
||||
_mockSqLiteJsonCacheManagerFactory
|
||||
.Setup(f => f.CreateForUser("AccountAddFlow"))
|
||||
.Returns(_mockAccountAddLockStorage.Object);
|
||||
|
||||
_accountManager = new AccountManager(
|
||||
_mockApplication.Object,
|
||||
_mockLogger.Object,
|
||||
_mockGraphQLClientFactory.Object,
|
||||
_mockSpeckleHttp.Object,
|
||||
_mockAccountFactory,
|
||||
_mockAuthFlow.Object,
|
||||
_mockSqLiteJsonCacheManagerFactory.Object
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
private static Account CreateTestAccount(string id)
|
||||
{
|
||||
|
||||
@@ -48,7 +48,7 @@ public class CredentialInfrastructure : IDisposable
|
||||
{
|
||||
Fixtures.UpdateOrSaveAccount(s_testAccount1);
|
||||
Fixtures.UpdateOrSaveAccount(s_testAccount2);
|
||||
Fixtures.UpdateOrSaveAccount(s_testAccount3);
|
||||
Fixtures.SaveLocalAccount(s_testAccount3);
|
||||
|
||||
var serviceProvider = TestServiceSetup.GetServiceProvider();
|
||||
_accountManager = serviceProvider.GetRequiredService<IAccountManager>();
|
||||
@@ -60,6 +60,7 @@ public class CredentialInfrastructure : IDisposable
|
||||
Fixtures.DeleteLocalAccount(s_testAccount1.id);
|
||||
Fixtures.DeleteLocalAccount(s_testAccount2.id);
|
||||
Fixtures.DeleteLocalAccount(s_testAccount3.id);
|
||||
Fixtures.DeleteLocalAccountFile();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -92,7 +93,7 @@ public class CredentialInfrastructure : IDisposable
|
||||
{
|
||||
var accs = _accountManager.GetAccounts(target.serverInfo.url).ToList();
|
||||
|
||||
accs.Should().HaveCount(1);
|
||||
accs.Count.Should().Be(1);
|
||||
|
||||
var acc = accs[0];
|
||||
|
||||
@@ -102,4 +103,24 @@ public class CredentialInfrastructure : IDisposable
|
||||
acc.refreshToken.Should().Be(target.refreshToken);
|
||||
acc.token.Should().Be(target.token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureLocalIdentifiers_AreUniqueAcrossServers()
|
||||
{
|
||||
// Accounts with the same user ID in different servers should always result in different local identifiers.
|
||||
string id = "12345";
|
||||
var acc1 = new Account
|
||||
{
|
||||
serverInfo = new ServerInfo { url = "https://speckle.xyz" },
|
||||
userInfo = new UserInfo { id = id },
|
||||
}.GetLocalIdentifier();
|
||||
|
||||
var acc2 = new Account
|
||||
{
|
||||
serverInfo = new ServerInfo { url = "https://app.speckle.systems" },
|
||||
userInfo = new UserInfo { id = id },
|
||||
}.GetLocalIdentifier();
|
||||
|
||||
acc1.Should().NotBe(acc2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 Speckle.Sdk.Common;
|
||||
using Speckle.Sdk.Credentials;
|
||||
using Speckle.Sdk.Logging;
|
||||
using Speckle.Sdk.Transports;
|
||||
|
||||
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 string s_accountPath = Path.Combine(
|
||||
SpecklePathProvider.AccountsFolderPath,
|
||||
"TestAccount.json"
|
||||
);
|
||||
|
||||
public static void UpdateOrSaveAccount(Account account)
|
||||
{
|
||||
DeleteLocalAccount(account.id.NotNull());
|
||||
@@ -16,5 +22,13 @@ public abstract class Fixtures
|
||||
s_accountStorage.SaveObjectSync(account.id, serializedObject);
|
||||
}
|
||||
|
||||
public static void SaveLocalAccount(Account account)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(account);
|
||||
File.WriteAllText(s_accountPath, json);
|
||||
}
|
||||
|
||||
public static void DeleteLocalAccount(string id) => s_accountStorage.DeleteObject(id);
|
||||
|
||||
public static void DeleteLocalAccountFile() => File.Delete(s_accountPath);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user