mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 09:06:37 +08:00
dbc09b8e22
# Description Adopt [`System.TimeProvider`](https://learn.microsoft.com/dotnet/api/system.timeprovider) across the stack so that every place that creates timers, runs duration math, or schedules periodic work is mockable, deterministic in tests, and immune to wall-clock changes. The migration touches Core, Core.Types, Server, Client, PubSub, Configuration, Gds (server + client), Lds, and the Quickstarts/Console reference apps. Per-component changes follow the same pattern: 1. Add a `private readonly TimeProvider m_timeProvider;` field. 2. Accept a nullable `TimeProvider? timeProvider = null` as the **last** constructor parameter (new overload; legacy constructor stays for binary compatibility and delegates with `timeProvider: null`). 3. Initialise the field with `m_timeProvider = timeProvider ??= TimeProvider.System;`. 4. Replace `new Timer(...)`, `Stopwatch`, `HiResClock.*`, `DateTime.UtcNow`-based duration math, and `Task.Delay(delay, ct)` in timing loops with the matching `m_timeProvider` API (`CreateTimer`, `GetTimestamp` / `GetElapsedTime`, `GetUtcNow().UtcDateTime`, `Task.Delay(delay, m_timeProvider, ct)`). 5. Wire the provider through DI: `AddOpcUaServerBuilder` / `AddOpcUaClientBuilder` register `TimeProvider.System` via `TryAddSingleton<TimeProvider>()` so tests can swap in `Microsoft.Extensions.Time.Testing.FakeTimeProvider`. `HiResClock` itself is kept (so the `ApplicationConfiguration.DisableHiResClock` schema flag still round-trips), but **every public member** is now `[Obsolete]` with a single shared message pointing to the new migration-guide section. The few internal call sites that still need to set/read `HiResClock` are wrapped with `#pragma warning disable CS0618`. ### Highlights per component - **Core / Stack** — `Opc.Ua.Core`, `Opc.Ua.Core.Types`, channel pipeline (`UaSCBinaryClientChannel`, `ChannelToken`, `UaSCBinaryChannel`), `ApplicationConfiguration`, certificate validators, `JwksIssuerKeyResolver`, `TimeProviderExtensions` polyfills (`GetTimestampMilliseconds`, `GetTickCount`, `CancellationTokenSource(timeout, timeProvider)`, `Task.Delay(delay, provider, ct)` polyfill for older TFMs). - **Server** — session lockout, subscription pipeline (publish/sample/keep-alive/lifetime), `MonitoredItemQueue`, alarm sampling rate, `IntervalRunner`, server diagnostics, configuration state machines, hosted services. - **Client** — `Session`, managed/V2 subscription engines, reconnect handlers, channel keep-alive, periodic publishing, complex-type discovery polling. - **PubSub** — `IntervalRunner` pipeline, message-receive timers, MQTT/UDP keep-alive, publishers/readers. - **Gds / Lds** — `LdsServer`, `LinqApplicationsDatabase` expiry housekeeping, GDS clients (push, pull), `DirectoryCertificateStore`, `ConnectionStateMachine`, classic subscription engine. - **Quickstarts / Console samples** — `SampleNodeManager`, `ReferenceNodeManager`, `AlarmNodeManager`, `BoilerState`, `MemoryBufferState`, `PublishedValuesWrites`. Samples resolve via `(Server as ITimeProviderProvider)?.TimeProvider ?? TimeProvider.System` so existing ctor signatures stay binary-compatible. - **Tests** — `Opc.Ua.PubSub.Tests` adds `IntervalRunnerTests`; existing `SubscriptionTests`, `UaPublisherTests`, and `TestDataSystem` move off `HiResClock`. Added `Microsoft.Extensions.TimeProvider.Testing` package for `FakeTimeProvider` (project-load warning surfaces on net48/net472 only — runtime is .NET 8+). ### Migration guide New section [**Time and Timer abstraction (`TimeProvider`)**](Docs/MigrationGuide.md#time-and-timer-abstraction-timeprovider) documents: - The canonical replacement table (`HiResClock.UtcNow` → `timeProvider.GetUtcNow().UtcDateTime`, `HiResClock.TickCount64` → `timeProvider.GetTimestamp()`, `new Timer` → `timeProvider.CreateTimer`, `Task.Delay(delay, ct)` → `Task.Delay(delay, timeProvider, ct)`, …). - The `TimeProvider? timeProvider = null` + `??=` constructor pattern. - DI wiring and how to swap in `FakeTimeProvider` in tests. - Before/after snippets for `HiResClock.TickCount64`, `HiResClock.UtcNow`, and `new Timer(...)`. ## Related Issues - Fixes #2458 ## Checklist - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received.
360 lines
14 KiB
C#
360 lines
14 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 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.Globalization;
|
|
using System.Reflection;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Opc.Ua.Identity;
|
|
|
|
namespace Opc.Ua.Client
|
|
{
|
|
/// <summary>
|
|
/// Adapts a GDS KeyCredentialServiceClient to the client-side <see cref="IAccessTokenProvider"/> surface.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The returned issued token uses the experimental vendor profile URI
|
|
/// <c>urn:opcfoundation:netstandard:profile:authentication:keycredential</c> and is intended only for
|
|
/// closed deployments that have enabled the matching server-side bridge authenticator.
|
|
/// </remarks>
|
|
public sealed class GdsKeyCredentialAccessTokenProvider : IAccessTokenProvider, IDisposable
|
|
{
|
|
/// <summary>
|
|
/// Vendor profile URI used by the KeyCredential bridge token.
|
|
/// </summary>
|
|
public const string ProfileUri =
|
|
"urn:opcfoundation:netstandard:profile:authentication:keycredential";
|
|
|
|
private static readonly TimeSpan s_defaultCacheLifetime = TimeSpan.FromMinutes(5);
|
|
private static readonly TimeSpan s_refreshSkew = TimeSpan.FromSeconds(30);
|
|
private readonly Func<CancellationToken, ValueTask<GdsIssuedKeyCredential>> m_acquireCredential;
|
|
private readonly TimeProvider m_timeProvider;
|
|
private readonly SemaphoreSlim m_lock = new(1, 1);
|
|
private GdsIssuedKeyCredential? m_cachedCredential;
|
|
private bool m_disposed;
|
|
|
|
/// <summary>
|
|
/// Creates a provider that wraps an Opc.Ua.Gds.Client.KeyCredentialServiceClient instance.
|
|
/// </summary>
|
|
public GdsKeyCredentialAccessTokenProvider(
|
|
object keyCredentialServiceClient,
|
|
string authorityUri,
|
|
string applicationUri,
|
|
ByteString publicKey = default,
|
|
string? securityPolicyUri = null,
|
|
ArrayOf<NodeId>? requestedRoles = null,
|
|
TimeSpan? cacheLifetime = null,
|
|
TimeProvider? timeProvider = null)
|
|
: this(
|
|
new ReflectionKeyCredentialClient(
|
|
keyCredentialServiceClient,
|
|
applicationUri,
|
|
publicKey,
|
|
securityPolicyUri,
|
|
requestedRoles ?? []).AcquireAsync,
|
|
authorityUri,
|
|
cacheLifetime,
|
|
timeProvider)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a provider from a credential acquisition delegate.
|
|
/// </summary>
|
|
public GdsKeyCredentialAccessTokenProvider(
|
|
Func<CancellationToken, ValueTask<GdsIssuedKeyCredential>> acquireCredential,
|
|
string authorityUri,
|
|
TimeSpan? cacheLifetime = null,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
m_acquireCredential = acquireCredential ?? throw new ArgumentNullException(nameof(acquireCredential));
|
|
AuthorityUri = authorityUri ?? throw new ArgumentNullException(nameof(authorityUri));
|
|
CacheLifetime = cacheLifetime ?? s_defaultCacheLifetime;
|
|
m_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public string AuthorityUri { get; }
|
|
|
|
/// <summary>
|
|
/// Lifetime assigned to credentials when the GDS response does not carry an expiration.
|
|
/// </summary>
|
|
public TimeSpan CacheLifetime { get; }
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask<AccessToken> AcquireAsync(
|
|
AuthorizationServerMetadata metadata,
|
|
CancellationToken ct = default)
|
|
{
|
|
ThrowIfDisposed();
|
|
GdsIssuedKeyCredential credential = await GetCredentialAsync(ct).ConfigureAwait(false);
|
|
string nonce = CreateNonce();
|
|
DateTime issuedAt = m_timeProvider.GetUtcNow().UtcDateTime;
|
|
long issuedAtSeconds = new DateTimeOffset(issuedAt).ToUnixTimeSeconds();
|
|
string proof = CreateProof(
|
|
credential.CredentialSecret,
|
|
credential.CredentialId,
|
|
nonce,
|
|
issuedAtSeconds);
|
|
byte[] tokenData = Encoding.UTF8.GetBytes(
|
|
"{\"credentialId\":\"" +
|
|
EscapeJson(credential.CredentialId) +
|
|
"\",\"nonce\":\"" +
|
|
nonce +
|
|
"\",\"issuedAt\":" +
|
|
issuedAtSeconds.ToString(CultureInfo.InvariantCulture) +
|
|
",\"proof\":\"" +
|
|
proof +
|
|
"\"}");
|
|
|
|
#pragma warning disable CA2000 // Ownership transfers to caller; TODO: remove when analyzer models ValueTask ownership.
|
|
return new AccessToken(
|
|
ProfileUri,
|
|
tokenData,
|
|
credential.ExpiresAt,
|
|
credential.CredentialId,
|
|
credential.GrantedScopes);
|
|
#pragma warning restore CA2000
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Dispose()
|
|
{
|
|
if (m_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
m_disposed = true;
|
|
m_lock.Dispose();
|
|
m_cachedCredential?.Dispose();
|
|
}
|
|
|
|
private async ValueTask<GdsIssuedKeyCredential> GetCredentialAsync(CancellationToken ct)
|
|
{
|
|
await m_lock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
ThrowIfDisposed();
|
|
if (m_cachedCredential != null &&
|
|
m_cachedCredential.ExpiresAt - s_refreshSkew > m_timeProvider.GetUtcNow().UtcDateTime)
|
|
{
|
|
return m_cachedCredential.Copy();
|
|
}
|
|
|
|
m_cachedCredential?.Dispose();
|
|
GdsIssuedKeyCredential issued = await m_acquireCredential(ct).ConfigureAwait(false);
|
|
if (issued.ExpiresAt == DateTime.MinValue || issued.ExpiresAt == default)
|
|
{
|
|
issued = issued with { ExpiresAt = m_timeProvider.GetUtcNow().UtcDateTime.Add(CacheLifetime) };
|
|
}
|
|
m_cachedCredential = issued.Copy();
|
|
return issued;
|
|
}
|
|
finally
|
|
{
|
|
m_lock.Release();
|
|
}
|
|
}
|
|
|
|
private static string CreateNonce()
|
|
{
|
|
byte[] nonce = new byte[32];
|
|
using (var rng = RandomNumberGenerator.Create())
|
|
{
|
|
rng.GetBytes(nonce);
|
|
}
|
|
return Base64UrlEncode(nonce);
|
|
}
|
|
|
|
private static string CreateProof(byte[] secret, string credentialId, string nonce, long issuedAt)
|
|
{
|
|
string input = credentialId +
|
|
"\n" +
|
|
nonce +
|
|
"\n" +
|
|
issuedAt.ToString(CultureInfo.InvariantCulture);
|
|
using var hmac = new HMACSHA256(secret);
|
|
return Base64UrlEncode(hmac.ComputeHash(Encoding.UTF8.GetBytes(input)));
|
|
}
|
|
|
|
private static string Base64UrlEncode(byte[] bytes)
|
|
{
|
|
return Convert.ToBase64String(bytes)
|
|
.TrimEnd('=')
|
|
.Replace('+', '-')
|
|
.Replace('/', '_');
|
|
}
|
|
|
|
private static string EscapeJson(string value)
|
|
{
|
|
#pragma warning disable CA1307 // StringComparison overload is not available on every supported TFM.
|
|
return value.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
|
#pragma warning restore CA1307
|
|
}
|
|
|
|
private void ThrowIfDisposed()
|
|
{
|
|
if (m_disposed)
|
|
{
|
|
throw new ObjectDisposedException(nameof(GdsKeyCredentialAccessTokenProvider));
|
|
}
|
|
}
|
|
|
|
private sealed class ReflectionKeyCredentialClient
|
|
{
|
|
private readonly object m_client;
|
|
private readonly MethodInfo m_startRequest;
|
|
private readonly MethodInfo m_finishRequest;
|
|
private readonly string m_applicationUri;
|
|
private readonly ByteString m_publicKey;
|
|
private readonly string? m_securityPolicyUri;
|
|
private readonly ArrayOf<NodeId> m_requestedRoles;
|
|
|
|
public ReflectionKeyCredentialClient(
|
|
object client,
|
|
string applicationUri,
|
|
ByteString publicKey,
|
|
string? securityPolicyUri,
|
|
ArrayOf<NodeId> requestedRoles)
|
|
{
|
|
m_client = client ?? throw new ArgumentNullException(nameof(client));
|
|
if (string.IsNullOrWhiteSpace(applicationUri))
|
|
{
|
|
throw new ArgumentException("ApplicationUri must be supplied.", nameof(applicationUri));
|
|
}
|
|
m_applicationUri = applicationUri;
|
|
m_publicKey = publicKey;
|
|
m_securityPolicyUri = securityPolicyUri;
|
|
m_requestedRoles = requestedRoles;
|
|
#pragma warning disable IL2075 // Reflection is limited to the source-generated KeyCredential client shape.
|
|
Type type = client.GetType();
|
|
m_startRequest = type.GetMethod("StartRequestAsync", BindingFlags.Public | BindingFlags.Instance) ??
|
|
throw new ArgumentException("KeyCredentialServiceClient must expose StartRequestAsync.", nameof(client));
|
|
m_finishRequest = type.GetMethod("FinishRequestAsync", BindingFlags.Public | BindingFlags.Instance) ??
|
|
throw new ArgumentException("KeyCredentialServiceClient must expose FinishRequestAsync.", nameof(client));
|
|
#pragma warning restore IL2075
|
|
}
|
|
|
|
public async ValueTask<GdsIssuedKeyCredential> AcquireAsync(CancellationToken ct)
|
|
{
|
|
object? startResult = m_startRequest.Invoke(
|
|
m_client,
|
|
[
|
|
m_applicationUri,
|
|
m_publicKey,
|
|
m_securityPolicyUri,
|
|
m_requestedRoles,
|
|
ct
|
|
]);
|
|
NodeId requestId = await AwaitValueTaskAsync<NodeId>(startResult).ConfigureAwait(false);
|
|
|
|
object? finishResult = m_finishRequest.Invoke(
|
|
m_client,
|
|
[
|
|
requestId,
|
|
false,
|
|
ct
|
|
]);
|
|
(string? credentialId, ByteString credentialSecret, string _, string? securityPolicyUri, ArrayOf<NodeId> grantedRoles) =
|
|
await AwaitValueTaskAsync<(
|
|
string credentialId,
|
|
ByteString credentialSecret,
|
|
string certificateThumbprint,
|
|
string securityPolicyUri,
|
|
ArrayOf<NodeId> grantedRoles)>(finishResult)
|
|
.ConfigureAwait(false);
|
|
List<string> scopes = [];
|
|
if (!string.IsNullOrWhiteSpace(securityPolicyUri))
|
|
{
|
|
scopes.Add(securityPolicyUri);
|
|
}
|
|
foreach (NodeId role in grantedRoles)
|
|
{
|
|
scopes.Add(role.ToString());
|
|
}
|
|
return new GdsIssuedKeyCredential(
|
|
credentialId,
|
|
credentialSecret.ToArray(),
|
|
DateTime.MinValue,
|
|
[.. scopes]);
|
|
}
|
|
|
|
private static async ValueTask<T> AwaitValueTaskAsync<T>(object? valueTask)
|
|
{
|
|
if (valueTask == null)
|
|
{
|
|
throw new InvalidOperationException("GDS client returned null.");
|
|
}
|
|
if (valueTask is ValueTask<T> typed)
|
|
{
|
|
return await typed.ConfigureAwait(false);
|
|
}
|
|
if (valueTask is Task<T> task)
|
|
{
|
|
return await task.ConfigureAwait(false);
|
|
}
|
|
throw new InvalidOperationException("GDS client returned an unsupported async result.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Credential material issued by a GDS KeyCredentialService pull request.
|
|
/// </summary>
|
|
public sealed record GdsIssuedKeyCredential(
|
|
string CredentialId,
|
|
byte[] CredentialSecret,
|
|
DateTime ExpiresAt,
|
|
string[]? GrantedScopes = null) : IDisposable
|
|
{
|
|
/// <summary>
|
|
/// Creates a defensive copy.
|
|
/// </summary>
|
|
public GdsIssuedKeyCredential Copy()
|
|
{
|
|
return new GdsIssuedKeyCredential(
|
|
CredentialId,
|
|
(byte[])CredentialSecret.Clone(),
|
|
ExpiresAt,
|
|
GrantedScopes == null ? null : (string[])GrantedScopes.Clone());
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Dispose()
|
|
{
|
|
Array.Clear(CredentialSecret, 0, CredentialSecret.Length);
|
|
}
|
|
}
|
|
}
|