using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
///
/// Production for the machine-to-machine data plane (#520):
/// the client presents the static username apikey with an
/// otua_<keyId>_<secret> credential as the password. Replaces the LDAP adapter.
///
///
///
/// This runs inline on session activation. The SDK raises ImpersonateUser
/// synchronously while holding a SessionManager-wide event lock, so a slow authenticator does not
/// stall one request — it serializes every session activation server-wide. That is why
/// verification is a single indexed lookup plus one HMAC, and why there is no directory call,
/// no retry, and no network I/O on this path at all. It is the main practical advantage of keys
/// over the LDAP bind this replaces.
///
///
/// Fail closed, and say little. Every rejection returns the same opaque message. The
/// reason is logged server-side but never returned, so a caller cannot distinguish "no such key"
/// from "wrong secret" from "key disabled" and use the server as an oracle.
///
///
public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
{
///
/// Returned for every failure mode, deliberately uninformative. Public so a test can assert that
/// the failure modes really are indistinguishable, rather than restating the literal and passing
/// even if the implementation started differentiating them.
///
public const string DenyMessage = "Invalid API key.";
private readonly IApiKeyStore _store;
private readonly IOptions _options;
private readonly ILogger _log;
private readonly TimeProvider _time;
/// Initializes a new instance of the class.
/// The API key source.
/// The Security:ApiKeys options.
/// The logger.
/// Time provider, injected so expiry is testable.
public ApiKeyOpcUaUserAuthenticator(
IApiKeyStore store,
IOptions options,
ILogger log,
TimeProvider? time = null)
{
_store = store;
_options = options;
_log = log;
_time = time ?? TimeProvider.System;
}
///
public async Task AuthenticateUserNameAsync(
string username, string password, CancellationToken ct)
{
var opts = _options.Value;
// The username carries no information, but check it rather than ignore it: a client sending the
// wrong username gets an explicit denial it can diagnose, instead of "any username works".
if (!string.Equals(username, opts.Username, StringComparison.Ordinal))
{
_log.LogInformation(
"ApiKeyAuth: denied — username {Presented} is not the configured {Expected}",
username, opts.Username);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
if (!ApiKeySecret.TryParse(password, out var apiKeyId, out var secret))
{
_log.LogInformation("ApiKeyAuth: denied — credential is not a well-formed otua_ key");
return OpcUaUserAuthResult.Deny(DenyMessage);
}
var now = _time.GetUtcNow().UtcDateTime;
ApiKeyLookup? lookup;
try
{
lookup = await _store.FindAsync(apiKeyId, now, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
// The contract forbids throwing: an exception here escapes into the SDK's session-activation
// path and surfaces as BadInternalError. A store outage denies, it does not crash.
_log.LogError(ex, "ApiKeyAuth: key store threw for key id {ApiKeyId}", apiKeyId);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
if (lookup is null)
{
_log.LogInformation("ApiKeyAuth: denied — no enabled, unexpired key with id {ApiKeyId}", apiKeyId);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
bool verified;
try
{
verified = ApiKeySecret.Verify(secret, opts.Pepper, lookup.Key.SecretHash);
}
catch (ArgumentException ex)
{
// Thrown only when the pepper is unset. This is a deployment error, not a bad credential —
// log it loudly, because otherwise it presents as "every key is rejected" with no clue why.
_log.LogError(ex,
"ApiKeyAuth: {Section}:Pepper is not configured — every key will be rejected until it is",
ApiKeyOptions.SectionName);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
if (!verified)
{
_log.LogInformation("ApiKeyAuth: denied — secret mismatch for key id {ApiKeyId}", apiKeyId);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
var roles = ApiKeyInterimRoleMapper.Map(lookup.Grants, lookup.Key.Principal);
_log.LogInformation(
"ApiKeyAuth: granted key {Name} ({ApiKeyId}) as principal {Principal} with roles [{Roles}]",
lookup.Key.Name, apiKeyId, lookup.Key.Principal, string.Join(",", roles));
return OpcUaUserAuthResult.Allow(lookup.Key.Principal, roles);
}
}