/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; using Opc.Ua; using Opc.Ua.Bindings; using Opc.Ua.Client; using Opc.Ua.Identity; namespace Microsoft.Extensions.DependencyInjection { /// /// extensions provided by /// Opc.Ua.Client: register OPC UA client services (telemetry, /// session factory, factory) for /// dependency-injected applications. /// /// /// Mirrors the server-side .AddServer(...) pattern. The /// returned can be extended with /// further registrations. /// public static class OpcUaClientBuilderExtensions { /// /// Default section name used by the /// overload. /// public const string DefaultConfigurationSection = "OpcUa:Client"; /// /// Registers OPC UA client services and a lazy /// factory for /// . The first call to the factory /// connects and caches the session; subsequent calls return the /// cached instance. /// /// The OPC UA builder. /// Configuration delegate for /// . Must set /// and /// . /// An for chaining. /// /// or is null. public static IOpcUaClientBuilder AddClient( this IOpcUaBuilder builder, Action configure) { if (builder is null) { throw new ArgumentNullException(nameof(builder)); } if (configure is null) { throw new ArgumentNullException(nameof(configure)); } var options = new OpcUaClientOptions(); configure(options); builder.Services.TryAddSingleton(options); RegisterCoreServices(builder.Services); return new OpcUaClientBuilder(builder.Services); } /// /// Registers OPC UA client services with options bound from the /// supplied section /// (OpcUa:Client). /// /// /// AOT-safe: bound by the .NET 8+ configuration binding source /// generator (EnableConfigurationBindingGenerator). /// /// The OPC UA builder. /// Configuration root containing /// the OpcUa:Client section. /// /// or is null. public static IOpcUaClientBuilder AddClient( this IOpcUaBuilder builder, IConfiguration configuration) { if (configuration is null) { throw new ArgumentNullException(nameof(configuration)); } return builder.AddClient(configuration.GetSection(DefaultConfigurationSection)); } /// /// Registers OPC UA client services with options bound from the /// supplied . /// /// /// AOT-safe: bound by the .NET 8+ configuration binding source /// generator (EnableConfigurationBindingGenerator). /// /// The OPC UA builder. /// Configuration section to bind. /// /// or is null. public static IOpcUaClientBuilder AddClient( this IOpcUaBuilder builder, IConfigurationSection section) { if (builder is null) { throw new ArgumentNullException(nameof(builder)); } if (section is null) { throw new ArgumentNullException(nameof(section)); } var options = new OpcUaClientOptions(); section.Bind(options); builder.Services.TryAddSingleton(options); RegisterCoreServices(builder.Services); return new OpcUaClientBuilder(builder.Services); } /// /// Registers a client identity provider implementation. /// /// The concrete identity-provider type to register. public static IOpcUaClientBuilder AddIdentityProvider< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>( this IOpcUaClientBuilder builder) where TProvider : class, IClientIdentityProvider { if (builder == null) { throw new ArgumentNullException(nameof(builder)); } builder.Services.AddSingleton(); builder.Services.AddSingleton( sp => sp.GetRequiredService()); return builder; } /// /// Registers a composite client identity provider built from the /// supplied shortcut configuration. /// public static IOpcUaClientBuilder AddIdentityProvider( this IOpcUaClientBuilder builder, Action configure) { if (builder == null) { throw new ArgumentNullException(nameof(builder)); } if (configure == null) { throw new ArgumentNullException(nameof(configure)); } var compositeBuilder = new CompositeClientIdentityProviderBuilder(); configure(compositeBuilder); builder.Services.AddSingleton( compositeBuilder.Build()); return builder; } /// /// Registers client identity providers bound from configuration. /// public static IOpcUaClientBuilder AddIdentityProvider( this IOpcUaClientBuilder builder, IConfiguration section) { if (builder == null) { throw new ArgumentNullException(nameof(builder)); } if (section == null) { throw new ArgumentNullException(nameof(section)); } OpcUaClientIdentityOptions options = BindIdentityOptions(section); builder.Services.AddSingleton(options); builder.Services.AddSingleton( sp => BuildConfiguredIdentityProvider(sp, options)); return builder; } /// /// Registers an access-token provider implementation. /// /// The concrete access-token-provider type to register. public static IOpcUaClientBuilder AddAccessTokenProvider< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>( this IOpcUaClientBuilder builder) where TProvider : class, IAccessTokenProvider { if (builder == null) { throw new ArgumentNullException(nameof(builder)); } builder.Services.TryAddSingleton(); builder.Services.AddSingleton( sp => sp.GetRequiredService()); return builder; } /// /// Registers an access-token provider instance. /// public static IOpcUaClientBuilder AddAccessTokenProvider( this IOpcUaClientBuilder builder, IAccessTokenProvider instance) { if (builder == null) { throw new ArgumentNullException(nameof(builder)); } if (instance == null) { throw new ArgumentNullException(nameof(instance)); } builder.Services.AddSingleton(instance); return builder; } /// /// Registers an access-token provider factory. /// public static IOpcUaClientBuilder AddAccessTokenProvider( this IOpcUaClientBuilder builder, Func factory) { if (builder == null) { throw new ArgumentNullException(nameof(builder)); } if (factory == null) { throw new ArgumentNullException(nameof(factory)); } builder.Services.AddSingleton(sp => factory(sp) ?? throw new InvalidOperationException( "Access-token provider factory returned null.")); return builder; } private static OpcUaClientIdentityOptions BindIdentityOptions( IConfiguration section) { IConfiguration identitySection = section; IConfigurationSection nested = section.GetSection("Identity"); if (nested.Exists()) { identitySection = nested; } var options = new OpcUaClientIdentityOptions(); identitySection.Bind(options); return options; } private static CompositeClientIdentityProvider BuildConfiguredIdentityProvider( IServiceProvider sp, OpcUaClientIdentityOptions options) { if (options == null) { throw new ArgumentNullException(nameof(options)); } var providers = new List(); if (options.EnableAnonymous) { providers.Add(new AnonymousIdentityProvider()); } if (options.UserName != null) { providers.Add(CreateUserNameProvider(sp, options.UserName)); } if (options.X509 != null) { providers.Add(CreateX509Provider(sp, options.X509)); } if (options.IssuedToken != null) { providers.Add(CreateIssuedTokenProvider(sp, options.IssuedToken)); } if (providers.Count == 0) { throw new InvalidOperationException( "At least one client identity provider must be configured."); } ApplyConfiguredOrder(providers, options.Order); return new CompositeClientIdentityProvider(providers); } private static UserNamePasswordIdentityProvider CreateUserNameProvider( IServiceProvider sp, UserNameClientIdentityOptions options) { ValidateRequired(options.UserName, "UserName.UserName"); ValidateRequired(options.SecretName, "UserName.SecretName"); ValidateRequired(options.SecretStoreType, "UserName.SecretStoreType"); var passwordId = new SecretIdentifier( options.SecretName, options.SecretStoreType, options.SecretStorePath); return new UserNamePasswordIdentityProvider( options.UserName, sp.GetRequiredService(), passwordId); } private static X509ClientIdentityProvider CreateX509Provider( IServiceProvider sp, X509ClientIdentityOptions options) { ValidateRequired(options.StoreType, "X509.StoreType"); ValidateRequired(options.StorePath, "X509.StorePath"); if (!string.IsNullOrWhiteSpace(options.SubjectName) && !string.IsNullOrWhiteSpace(options.Thumbprint)) { throw new InvalidOperationException( "X509.SubjectName and X509.Thumbprint are mutually exclusive."); } if (string.IsNullOrWhiteSpace(options.SubjectName) && string.IsNullOrWhiteSpace(options.Thumbprint)) { throw new InvalidOperationException( "Either X509.SubjectName or X509.Thumbprint must be configured."); } var certificateId = new CertificateIdentifier { StoreType = options.StoreType, StorePath = options.StorePath, SubjectName = options.SubjectName, Thumbprint = options.Thumbprint }; return new X509ClientIdentityProvider( certificateId, sp.GetRequiredService(), sp.GetRequiredService()); } private static IssuedTokenIdentityProvider CreateIssuedTokenProvider( IServiceProvider sp, IssuedTokenClientIdentityOptions options) { ValidateRequired(options.ProfileUri, "IssuedToken.ProfileUri"); IAccessTokenProvider accessTokenProvider = ResolveAccessTokenProvider( sp, options.AuthorityUri); return new IssuedTokenIdentityProvider(accessTokenProvider, options.ProfileUri); } private static IAccessTokenProvider ResolveAccessTokenProvider( IServiceProvider sp, string? authorityUri) { var providers = new List(); providers.AddRange(sp.GetServices()); if (string.IsNullOrWhiteSpace(authorityUri)) { if (providers.Count == 1) { return providers[0]; } if (providers.Count == 0) { throw new InvalidOperationException( "IssuedToken identity requires a registered IAccessTokenProvider."); } throw new InvalidOperationException( "IssuedToken.AuthorityUri must be configured when multiple " + "IAccessTokenProvider services are registered."); } foreach (IAccessTokenProvider provider in providers) { if (string.Equals( provider.AuthorityUri, authorityUri, StringComparison.Ordinal)) { return provider; } } throw new InvalidOperationException( "No IAccessTokenProvider is registered for AuthorityUri '" + authorityUri + "'."); } private static void ApplyConfiguredOrder( List providers, IList order) { if (order == null || order.Count == 0 || providers.Count < 2) { return; } var priorities = new Dictionary(); for (int i = 0; i < order.Count; i++) { if (TryMapIdentityName(order[i], out UserTokenType tokenType) && !priorities.ContainsKey(tokenType)) { priorities.Add(tokenType, i); } } if (priorities.Count == 0) { return; } var ordered = new List(providers.Count); for (int i = 0; i < providers.Count; i++) { ordered.Add(new ProviderOrder( providers[i], GetProviderPriority(providers[i], priorities), i)); } ordered.Sort(CompareProviderOrder); providers.Clear(); foreach (ProviderOrder item in ordered) { providers.Add(item.Provider); } } private static int GetProviderPriority( IClientIdentityProvider provider, Dictionary priorities) { int priority = int.MaxValue; foreach (UserTokenType tokenType in provider.SupportedTokenTypes) { if (priorities.TryGetValue(tokenType, out int candidate) && candidate < priority) { priority = candidate; } } return priority; } private static int CompareProviderOrder(ProviderOrder left, ProviderOrder right) { int priority = left.Priority.CompareTo(right.Priority); return priority != 0 ? priority : left.OriginalIndex.CompareTo(right.OriginalIndex); } private static bool TryMapIdentityName(string name, out UserTokenType tokenType) { if (string.Equals(name, "X509", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "Certificate", StringComparison.OrdinalIgnoreCase)) { tokenType = UserTokenType.Certificate; return true; } if (Enum.TryParse(name, true, out UserTokenType parsed)) { tokenType = parsed; return true; } tokenType = default; return false; } private static void ValidateRequired(string value, string optionName) { if (string.IsNullOrWhiteSpace(value)) { throw new InvalidOperationException( optionName + " must be configured."); } } private static bool HasConfiguredIdentity(OpcUaClientIdentityOptions options) { return !options.EnableAnonymous || options.UserName != null || options.X509 != null || options.IssuedToken != null || options.Order.Count > 0; } private readonly struct ProviderOrder { public ProviderOrder( IClientIdentityProvider provider, int priority, int originalIndex) { Provider = provider; Priority = priority; OriginalIndex = originalIndex; } public IClientIdentityProvider Provider { get; } public int Priority { get; } public int OriginalIndex { get; } } private static void RegisterCoreServices(IServiceCollection services) { services.TryAddSingleton( sp => new ServiceProviderTelemetryContext(sp)); services.AddHttpClient(OpcUaHttpClientDefaults.ClientName) .AddStandardResilienceHandler(); services.TryAddSingleton(); services.TryAddSingleton(sp => { ITelemetryContext telemetry = sp.GetRequiredService(); OpcUaClientOptions options = sp.GetRequiredService(); TimeProvider? timeProvider = sp.GetService(); ApplicationConfiguration configuration = options.Configuration ?? throw new InvalidOperationException( "OpcUaClientOptions.Configuration is required to construct " + "the IClientChannelManager."); IOpcUaHttpClientFactory? httpClientFactory = sp.GetService(); ITransportChannelBindings? channelBindings = httpClientFactory == null ? null : new HttpsTransportChannelBindings(httpClientFactory); return new ClientChannelManager( configuration, telemetry, channelFactory: channelBindings, reconnectPolicy: null, timeProvider: timeProvider); }); services.TryAddSingleton(sp => { ITelemetryContext telemetry = sp.GetRequiredService(); OpcUaClientOptions options = sp.GetRequiredService(); TimeProvider? timeProvider = sp.GetService(); return new DefaultSessionFactory(telemetry) { SubscriptionEngineFactory = options.Session.SubscriptionEngineFactory ?? new DefaultSubscriptionEngineFactory(timeProvider), TimeProvider = timeProvider }; }); services.TryAddSingleton(sp => { ITelemetryContext telemetry = sp.GetRequiredService(); return new ManagedSessionFactory(telemetry); }); services.TryAddSingleton>>( sp => new ManagedSessionAccessor(sp).ConnectAsync); services.TryAddSingleton(sp => { ITelemetryContext telemetry = sp.GetRequiredService(); OpcUaClientOptions options = sp.GetRequiredService(); return ReverseConnectManagerActivator.Create(options, telemetry); }); services.AddOpcUa(); } /// /// Builds a on first resolution /// when client reverse-connect options are configured. The /// configured listener URLs are added, the manager's /// /// is invoked using the application configuration from /// , and the options are mirrored /// into so any /// other consumer reading the application configuration sees the /// same data. /// private static class ReverseConnectManagerActivator { public static ReverseConnectManager Create( OpcUaClientOptions options, ITelemetryContext telemetry) { var manager = new ReverseConnectManager(telemetry); ClientReverseConnectOptions? rcOptions = options.ReverseConnect; if (rcOptions == null || rcOptions.ClientEndpointUrls.Count == 0) { return manager; } ApplicationConfiguration? configuration = options.Configuration ?? throw new InvalidOperationException( "OpcUaClientOptions.Configuration must be set before " + "resolving ReverseConnectManager."); configuration.ClientConfiguration ??= new ClientConfiguration(); var clientEndpoints = new ReverseConnectClientEndpoint[ rcOptions.ClientEndpointUrls.Count]; for (int i = 0; i < rcOptions.ClientEndpointUrls.Count; i++) { clientEndpoints[i] = new ReverseConnectClientEndpoint { EndpointUrl = rcOptions.ClientEndpointUrls[i] }; } configuration.ClientConfiguration.ReverseConnect = new ReverseConnectClientConfiguration { ClientEndpoints = new ArrayOf(clientEndpoints), HoldTime = rcOptions.HoldTimeMs, WaitTimeout = rcOptions.WaitTimeoutMs }; foreach (string url in rcOptions.ClientEndpointUrls) { manager.AddEndpoint(new Uri(url)); } manager.StartService(configuration); return manager; } } private sealed class OpcUaClientBuilder : IOpcUaClientBuilder { public OpcUaClientBuilder(IServiceCollection services) { Services = services; } public IServiceCollection Services { get; } } /// /// Lazily creates and caches the connected . /// Multiple awaiters of the factory delegate share the single /// connection task. /// private sealed class ManagedSessionAccessor { public ManagedSessionAccessor(IServiceProvider sp) { m_sp = sp; } public Task ConnectAsync(CancellationToken ct) { lock (m_gate) { m_connectTask ??= ConnectCoreAsync(ct); return m_connectTask; } } private Task ConnectCoreAsync(CancellationToken ct) { OpcUaClientOptions options = m_sp.GetRequiredService(); ITelemetryContext telemetry = m_sp.GetRequiredService(); if (options.Configuration == null) { throw new InvalidOperationException( "OpcUaClientOptions.Configuration must be set before " + "resolving ManagedSession."); } var builder = new ManagedSessionBuilder(options.Configuration, telemetry); if (options.Session.Endpoint != null) { builder.UseEndpoint(options.Session.Endpoint); } builder.WithSessionName(options.Session.SessionName) .WithSessionTimeout(options.Session.SessionTimeout) .WithCheckDomain(options.Session.CheckDomain) .WithReconnectPolicy(_ => options.Session.ReconnectPolicy); IClientIdentityProvider? identityProvider = options.Session.IdentityProvider ?? ResolveIdentityProvider(); if (identityProvider != null) { builder.WithIdentityProvider(identityProvider); } #pragma warning disable CS0618 // Legacy eager identity remains supported when no provider is configured. else if (options.Session.Identity != null) { builder.WithUserIdentity(options.Session.Identity); } #pragma warning restore CS0618 TimeProvider? timeProvider = options.Session.TimeProvider ?? m_sp.GetService(); if (timeProvider != null) { builder.WithTimeProvider(timeProvider); } if (options.Session.PreferredLocales is { Count: > 0 } locales) { string[] arr = new string[locales.Count]; for (int i = 0; i < locales.Count; i++) { arr[i] = locales[i]; } builder.WithPreferredLocales(arr); } if (options.Session.SubscriptionEngineFactory != null) { builder.UseSubscriptionEngine(options.Session.SubscriptionEngineFactory); } if (options.Session.EnableServerRedundancy) { builder.WithServerRedundancy(); } if (options.Session.TransferSubscriptionsOnRecreate) { builder.WithTransferSubscriptionsOnRecreate(); } if (options.Session.PoolNotifications) { builder.WithPoolNotifications(); } IClientChannelManager? mgr = m_sp.GetService(); if (mgr != null) { builder.WithChannelManager(mgr); } return builder.ConnectAsync(ct); } private IClientIdentityProvider? ResolveIdentityProvider() { IEnumerable registered = m_sp.GetServices(); var providers = new List(); providers.AddRange(registered); OpcUaClientOptions clientOptions = m_sp.GetRequiredService(); OpcUaClientIdentityOptions identityOptions = m_sp.GetService() ?? clientOptions.Identity; if (providers.Count == 0) { return HasConfiguredIdentity(identityOptions) ? BuildConfiguredIdentityProvider(m_sp, identityOptions) : null; } ApplyConfiguredOrder(providers, identityOptions.Order); if (providers.Count == 1) { return providers[0]; } return new CompositeClientIdentityProvider(providers); } private readonly IServiceProvider m_sp; private Task? m_connectTask; private readonly Lock m_gate = new(); } } }