Skip to content

SAIF.NetCore.NtlmApiAuth security and authorization

This library implements no authentication; it relies entirely on host Windows/NTLM (IIS) authentication for the caller identity. It is a dual-identity authorization add-on: every named policy role is checked against both the authenticated caller and an optional header-named "on behalf of" end user, with the actual AD-role decision delegated to SAIF.NetCore.AuthService's remote SecurityAPI.

Authorization architecture

Authorization is centralized and remote: this library builds ASP.NET Core policies locally but never decides a role check itself — every decision is a GET call to SecurityAPI made through AuthService's ISecurityClient.IsAuthorized, reached up to twice per requirement evaluation (once for the caller, once for the on-behalf-of user).

flowchart TD
    Header[OnBehalfOfUser header] --> Claim[OnBehalfOfUserClaimMiddleware adds claim]
    Claim --> Policy["Authorize Policy = name"]
    Policy --> Req[RoleRequirement.HandleRequirementAsync]
    Req --> Svc[NtlmApiAuthorizationService.IsAuthorized]
    Svc --> Sec["AuthService: SecurityClient.IsAuthorized"]
    Sec --> API["SecurityAPI GET /v1/Users/IsAuthorized/user/role"]
    API --> AD["(Active Directory group membership)"]

AD resource catalog

Resource name Purpose Enforcement point Source
(none hard-coded) Roles are consumer-supplied per named policy via ConfigureRoleAuthorizationOptions.AddPolicies; each role is checked for the caller and, if present, the on-behalf-of user. ConfigureRoleAuthorizationOptions.AddPolicies → RoleRequirement.HandleRequirementAsync → NtlmApiAuthorizationService.IsAuthorized → AuthService ISecurityClient.IsAuthorized src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Options/ConfigureRoleAuthorizationOptions.cs, Authorization/RoleRequirement.cs, Services/NtmlApiAuthorizationService.cs

No AD group or resource-name literal exists in library code. Every environment-suffix rewrite (e.g. XResource → XTestResource) is performed by the embedded AuthService call documented in Dual-identity authorization, confirmed against the repository's 10.x SecurityClient source; applicability to the pinned 8.2023.319.2 package is inferred*.

Authorization enforcement points

Attribute-based authorization

Controller / Action Attribute Resource name(s) Source
Consumer-defined controllers/actions [Authorize(Policy = "<name>")] (framework attribute; this library does not define a custom attribute) Consumer-defined, resolved through the named policy Consumer code

Programmatic authorization checks

Class Method Resource / Role Check type Source
RoleRequirement HandleRequirementAsync This instance's own _role (self-handling requirement; cross-evaluates every requirement in the policy context) IsAuthorized (via delegation) src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Authorization/RoleRequirement.cs
NtlmApiAuthorizationService IsAuthorized(requestUser, onBehalfOfUser, role) Caller identity, and on-behalf-of identity when present IsAuthorized (delegates to AuthService ISecurityClient.IsAuthorized, confirmed against the repository's 10.x source; applicability to the pinned 8.2023.319.2 package is inferred*) src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Services/NtmlApiAuthorizationService.cs
ConfigureRoleAuthorizationOptions AddPolicies Policy factory — builds one RoleRequirement per role Policy construction src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Options/ConfigureRoleAuthorizationOptions.cs

Data-level authorization

None. No entity-ownership or scope check exists in this library. The OnBehalfOfUser claim is the natural input for consumer-side data scoping, but it is unverified (any authenticated caller can name any user) and can be stale if the originating IHttpJsonClient is shared across users — it must not be treated as a verified identity for ownership decisions.

Segregation of duties

None. No SOD classes or privilege-combination checks exist in this library.

Security interfaces and contracts

Interface / Class Role Consumers Source
INtlmApiAuthorizationService Dual-identity authorization decision RoleRequirement src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Services/INtmlApiAuthorizationService.cs
IOnBehalfOfUserClaimService Header→claim conversion OnBehalfOfUserClaimMiddleware src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Services/IOnBehalfOfUserClaimService.cs
ICurrentUserService Current-user resolution (client side) IHttpJsonClientExtensions.ConfigureOnBehalfOfUserAuthorization src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Services/ICurrentUserService.cs
ConfigureRoleAuthorizationOptions Abstract policy factory consumers subclass IServiceCollectionExtensions.AddNtlmApiAuthorization<T> src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Options/ConfigureRoleAuthorizationOptions.cs
ISecurityClient (consumed, SAIF.NetCore.AuthService) Remote role-check client NtlmApiAuthorizationService src/SAIF.NetCore.AuthService/SAIF.NetCore.AuthService/SAIF.NetCore.AuthService/Interfaces/ISecurityClient.cs

Security findings and risks

Severity scale: High (realistic path to acting with another user's authority), Medium (weakens a control or enables identity spoofing under plausible conditions), Low (fails closed / limited impact), Informational.

# Finding Severity Source
SEC-NTLM-01 Cross-user identity leakage via persisted header: a client configured by Configure an outbound client to send OnBehalfOfUser persists the first user's name on every later request if the IHttpJsonClient instance outlives one user (e.g. Singleton registration or a cached client). Server-side dual-identity authorization then evaluates a later user's request under an earlier user's name, granting that user's entitlements and misattributing the action.* Repo defaults (Transient IHttpJsonClient, Scoped sample typed client) are safe, but nothing in the library prevents or detects an unsafe lifetime. High src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Extensions/IHttpJsonClientExtensions.cs
SEC-NTLM-02 On-behalf-of check is caller-opt-in (fail-open by omission): when the header is absent or blank, Dual-identity authorization authorizes on the caller identity alone, so a service account holding the role is authorized for any request it makes without the header — regardless of which end user triggered it.* Medium src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Services/NtmlApiAuthorizationService.cs
SEC-NTLM-03 Unverified delegation header (impersonation naming): any authenticated caller may name any user in OnBehalfOfUser; there is no allow-list of trusted delegating accounts and no signature/token. Because both identities must pass, the header cannot raise the caller's own privilege, but a user holding role R can act "as" any other R-holder for audit/data-scoping purposes. Medium src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Services/OnBehalfOfUserClaimService.cs
SEC-NTLM-04 Header value reaches the SecurityAPI route unnormalized: the on-behalf-of value is passed raw into the embedded AuthService call, which only replaces \ with / before interpolating it into the URL path. Whether the underlying IHttpJsonClient/RestSharp implementation URL-encodes that path segment is unknown.** If it does not, a crafted value containing /, .., ?, or # could change the path or query evaluated for the on-behalf-of half of the check and return an unintended result; this exploitability has not been tested, only reasoned from the absence of visible encoding in either library.* The caller-identity check is unaffected either way. Medium src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Authorization/RoleRequirement.cs
SEC-NTLM-05 DomainlessUser normalization defects: the @ index is computed before the \ strip, so SAIF\bob@saif.com becomes bob@saif; short tails can throw ArgumentOutOfRangeException. Effect is mostly wrong-name → deny or exception (fails closed). Low src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Extensions/StringExtensions.cs
SEC-NTLM-06 Middleware-order dependency silently disables the delegated check: the claim is added only if the identity is already authenticated when the middleware runs; under non-IIS hosting with the middleware placed before UseAuthentication(), the header would always be ignored.* This has not been exercised against a non-IIS host in this run; the IIS ordering (identity set before the pipeline runs) is the only ordering present in the source-inspected sample configuration, not a recorded host execution.* Medium (conditional) src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Services/OnBehalfOfUserClaimService.cs
SEC-NTLM-07 Multi-role semantics are AND in code but documented as OR in the README, and each self-handling requirement cross-evaluates every role in the policy, so SecurityAPI can be called up to 2·N² times per request for N roles.* Fails closed (more restrictive than documented) but can surprise developers following the README. Low src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Options/ConfigureRoleAuthorizationOptions.cs, Authorization/RoleRequirement.cs
SEC-NTLM-08 Forwarded header lost on retry / duplicate-key exceptions: Forward the inbound OnBehalfOfUser header does not itself enable persistence, so on a client whose options are otherwise left at their default a retried request can be sent without the header;* calling it alongside Configure an outbound client to send OnBehalfOfUser on the same client throws ArgumentException. Low src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Extensions/HttpContextAccessorExtensions.cs
SEC-NTLM-09 Fail-closed by exception: a SecurityClientException from the embedded AuthService call, or a DomainlessUser exception, propagates out of RoleRequirement.HandleRequirementAsync uncaught; the resulting HTTP response is a function of the host's own error handling and is inferred to be a server error (not a 403) rather than independently verified in this run.* Access is never granted on failure either way. Low src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Authorization/RoleRequirement.cs
SEC-NTLM-10 Swagger debug header is documentation-only: hiding the header parameter outside a debugger does not stop the server from honoring the header in any environment. Informational src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Extensions/SwaggerGenOptionsExtensions.cs
SEC-NTLM-11 A single INtlmApiAuthorizationService instance (Transient, but captured by the singleton AuthorizationOptions) is reused for the app lifetime; safe only while AuthService registers ISecurityClient as a singleton.* No per-request caching exists, so every evaluation re-queries SecurityAPI. Informational src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Options/ConfigureRoleAuthorizationOptions.cs
SEC-NTLM-12 Dead security surface and version skew: unused IEnvironmentClient injection and suffix constants; the public AuthorizationResource helper has different semantics from the live AuthService rewrite; AuthService is pinned at 8.2023.319.2 while the suffix-rewrite behavior is verified only against 10.x source.* Informational src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Services/NtmlApiAuthorizationService.cs, Constants.cs

Machine/application authorization

The authenticated caller in the double-hop scenario is typically the calling application's Windows identity (an app-pool service account or machine account). NtlmApiAuthorizationService requires this application identity to hold each role itself before the end user is considered — application authorization and user authorization are evaluated against the same role. DomainlessUser strips the domain but keeps a trailing $, so a machine account is sent to SecurityAPI as HOST$ with no distinct machine-group logic. Because the service account must hold user roles, it is a high-value principal: any request it makes without the OnBehalfOfUser header (SEC-NTLM-02) is authorized for every role it holds.

Notes

  • The embedded AuthService rewrite (SecurityClient.GetSuffixRole) only replaces a trailing, case-insensitive resource token on a role name with the configured SecurityClient:resourcesuffix value (e.g. XResource → XTestResource); a role name that does not already end in resource is left unchanged* — the rewrite does not append Resource/TestResource to every base name consumers supply.

Note: Statements marked with * were inferred from code structure, naming conventions, or contextual evidence where direct confirmation was not available in source artifacts. Statements marked with ** could not be confirmed; treat as unknown until verified.

Sources

  • src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Authorization/RoleRequirement.cs
  • src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Services/NtmlApiAuthorizationService.cs
  • src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Options/ConfigureRoleAuthorizationOptions.cs
  • src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Services/OnBehalfOfUserClaimService.cs
  • src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Extensions/IHttpJsonClientExtensions.cs, Extensions/HttpContextAccessorExtensions.cs, Extensions/StringExtensions.cs, Extensions/SwaggerGenOptionsExtensions.cs
  • src/SAIF.NetCore.NtlmApiAuth/src/SAIF.NetCore.NtlmApiAuth/Constants.cs
  • src/SAIF.NetCore.AuthService/SAIF.NetCore.AuthService/SAIF.NetCore.AuthService/Clients/SecurityClient.cs
  • src/SAIF.NetCore.NtlmApiAuth/README.md