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(); } }