using System.Security.Cryptography.X509Certificates;
using Google.Protobuf;
using Grpc.Core;
using Grpc.Net.Client;
namespace MFiles.Grpc.Samples;
///
/// A gRPC channel to an M-Files server, optionally logged in to one vault.
///
///
/// Create service clients on , e.g.
/// new IRPCSearch.IRPCSearchClient(connection.CallInvoker). Every call through it carries
/// the M-Files call metadata and the session, and fails with .
/// Dispose the connection to log out and close the channel.
///
public sealed class MFilesConnection : IAsyncDisposable
{
///
/// Largest message sent: what the server accepts by default (48 MiB), the same as M-Files' own
/// client. Received messages have no limit.
///
public const int MaxSendMessageSize = 48 * 1024 * 1024;
private readonly GrpcChannel _channel;
private readonly string _host;
private MFilesConnection(GrpcChannel channel, string host)
{
_channel = channel;
_host = host;
CallInvoker = new MFilesCallInvoker(channel.CreateCallInvoker(), this);
}
/// Make service clients on this.
public CallInvoker CallInvoker { get; }
/// The session from LogIn, or null when not logged in.
public SessionData? Session { get; private set; }
/// The session ID sent in mfiles-session-id-bin, or null when not logged in.
public ByteString? SessionId => Session?.SessionId;
/// The vault's metadata structure version from the latest call that reported it.
public int? MetadataStructureVersion { get; internal set; }
///
/// Opens a channel without logging in. Anonymous calls such as QueryVersion work on it.
///
public static MFilesConnection Open(MFilesSettings settings)
{
// [START create-channel]
var handler = new SocketsHttpHandler
{
// M-Files Cloud answers with a server affinity cookie (mfilesmsm); the handler's
// cookie container sends it back on later calls.
UseCookies = true,
// gRPC multiplexes calls on one HTTP/2 connection; allow more when it is busy.
EnableMultipleHttp2Connections = true,
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
};
if (settings.CaCertificatePath is { } caPath)
{
// Trust this root (e.g. an on-premises server's own CA) instead of the system's roots.
var roots = new X509Certificate2Collection();
roots.ImportFromPemFile(caPath);
handler.SslOptions.CertificateChainPolicy = new X509ChainPolicy
{
TrustMode = X509ChainTrustMode.CustomRootTrust,
// A private CA usually publishes no revocation list.
RevocationMode = X509RevocationMode.NoCheck,
};
handler.SslOptions.CertificateChainPolicy.CustomTrustStore.AddRange(roots);
}
// "http://" makes an unencrypted HTTP/2 connection (lab servers only).
string scheme = settings.UseTls ? "https" : "http";
var channel = GrpcChannel.ForAddress($"{scheme}://{settings.Address}", new GrpcChannelOptions
{
HttpHandler = handler,
// No limit (null) on received messages: responses can be much larger than gRPC's
// default of 4 MiB.
MaxReceiveMessageSize = null,
MaxSendMessageSize = MaxSendMessageSize,
});
// [END create-channel]
return new MFilesConnection(channel, settings.Host);
}
///
/// Opens a channel and logs in to settings.Vault with a token (MFILES_TOKEN) if one is set,
/// otherwise with the user name and password.
///
public static async Task ConnectAsync(
MFilesSettings settings, string clientName, CancellationToken cancellationToken = default)
{
var connection = Open(settings);
try
{
await connection.LogInAsync(settings, clientName, cancellationToken);
return connection;
}
catch
{
await connection.DisposeAsync();
throw;
}
}
///
/// Logs in to settings.Vault with a token (MFILES_TOKEN) if one is set, otherwise with the
/// user name and password.
///
public Task LogInAsync(
MFilesSettings settings, string clientName, CancellationToken cancellationToken = default)
{
string vault = settings.Vault ?? throw new InvalidOperationException("Set MFILES_VAULT.");
if (settings.Token is { } token)
{
return LogInWithTokenAsync(vault, token,
settings.AuthPlugin ?? throw new InvalidOperationException("Set MFILES_AUTH_PLUGIN."),
settings.AuthScope ?? throw new InvalidOperationException("Set MFILES_AUTH_SCOPE."),
clientName, cancellationToken);
}
return LogInAsync(vault,
settings.Username ?? throw new InvalidOperationException("Set MFILES_USERNAME."),
settings.Password ?? throw new InvalidOperationException("Set MFILES_PASSWORD."),
clientName, cancellationToken);
}
///
/// Logs in to a vault with an M-Files user name and password.
///
/// The vault GUID with braces.
/// Your application's name; it shows in the server's session list.
public Task LogInAsync(string vault, string username, string password,
string clientName, CancellationToken cancellationToken = default)
{
// [START log-in]
var authentication = new AuthDataClient
{
Type = AuthDataType.Credentials,
Data = new AuthDataClientUnion
{
Credentials = new Credentials
{
Username = username,
Password = password,
Type = CredentialsType.Mfiles, // M-Files authentication
},
},
};
// [END log-in]
return LogInAsync(vault, authentication, clientName, cancellationToken);
}
///
/// Logs in to a vault with a token from the vault's identity provider (SSO).
///
/// An ID or access token, as the authentication plugin expects.
/// The authentication plugin, e.g. from GetAuthenticationPlugins.
/// The plugin's configuration scope.
public Task LogInWithTokenAsync(string vault, string token, string pluginName,
string configurationScope, string clientName, CancellationToken cancellationToken = default)
{
// [START log-in-token]
var authentication = new AuthDataClient
{
Type = AuthDataType.Plugin,
Data = new AuthDataClientUnion
{
Plugin = new AuthDataClientUnion.Types.PluginData
{
// A one-step exchange: an all-zero 32-byte attempt ID.
AuthenticationAttemptIdentifier = new AuthenticationAttemptIdentifier
{
Data = ByteString.CopyFrom(new byte[32]),
},
PluginName = pluginName,
ConfigurationScope = configurationScope,
DataFormat = PluginAuthDataFormat.Unencrypted,
// The token is the only named value, "Token".
AuthData =
{
new NamedValue
{
Key = "Token",
Value = new TypedValue
{
Type = Datatype.Text,
Data = new TypedValueUnion { Text = token },
},
},
},
},
},
};
// [END log-in-token]
return LogInAsync(vault, authentication, clientName, cancellationToken);
}
private async Task LogInAsync(string vault, AuthDataClient authentication,
string clientName, CancellationToken cancellationToken)
{
// [START log-in-request]
var request = new LogInRequest
{
EnvironmentData = new EnvironmentData { ClientName = clientName, HostPlatform = ".NET" },
ClientData = new ClientData
{
Type = ClientType.ServerApi,
Language = "en",
// Needed for the 32-byte attempt ID of a token login.
Capabilities = new ClientCapabilities { LongAuthenticationAttemptIdentifier = true },
},
LoginData = new LoginData { ServerHostname = _host, VaultGuid = vault },
AuthenticationData = authentication,
};
var login = new IRPCLogin.IRPCLoginClient(CallInvoker);
var response = await login.LogInAsync(request, cancellationToken: cancellationToken);
if (response.SessionData is not { SessionId.IsEmpty: false } session)
throw new InvalidOperationException(
"LogIn returned no session; this helper supports one-step logins only.");
// Every later call sends this session ID in mfiles-session-id-bin (see MFilesCallInvoker).
Session = session;
// [END log-in-request]
return session;
}
///
/// Logs out, ending the session on the server. Does nothing when not logged in.
///
public async Task LogOutAsync(CancellationToken cancellationToken = default)
{
if (Session is null)
return;
// [START log-out]
var login = new IRPCLogin.IRPCLoginClient(CallInvoker);
await login.LogOutAsync(new LogOutRequest(), cancellationToken: cancellationToken);
// [END log-out]
Session = null;
}
///
/// Logs out if logged in (ignoring a failure, e.g. an expired session) and closes the channel.
///
public async ValueTask DisposeAsync()
{
try
{
await LogOutAsync();
}
catch (MFilesException)
{
Session = null;
}
_channel.Dispose();
}
}