wwauth/Google.Solutions.WWAuth/Adapters/Adfs/AdfsAdapterBase.cs (136 lines of code) (raw):
//
// Copyright 2022 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//
using Google.Apis.Logging;
using Google.Apis.Util;
using Google.Solutions.WWAuth.Data;
using System;
using System.DirectoryServices;
using System.DirectoryServices.ActiveDirectory;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace Google.Solutions.WWAuth.Adapters.Adfs
{
/// <summary>
/// Base class for AD FS adapters.
/// </summary>
internal abstract class AdfsAdapterBase : ITokenAdapter
{
protected ILogger Logger { get; }
/// <summary>
/// Base URL of AD FS, typically ending in '/adfs/'.
/// </summary>
public Uri IssuerUrl { get; }
/// <summary>
/// Kerberos SPN to verify before attempting a token
/// acquisition.
/// </summary>
protected abstract string ServicePrincipalName { get; }
/// <summary>
/// User agent to use for IWA requests. Include MSIE and
/// Trident in compatible versions because these user agents
/// are on the default "IWA" white-list in AD FS. Other
/// use agents would require explicit white-listing.
/// </summary>
public static string IwaUserAgent
=> $"{UserAgent.Default.Name}/{UserAgent.Default.Version} " +
"(compatible; MSIE 7.0; MSIE 8.0; MSIE 9.0; MSIE 10.0; Trident/7.0)";
protected NetworkCredential Credential
=> CredentialCache.DefaultNetworkCredentials;
protected AdfsAdapterBase(
Uri issuerUrl,
ILogger logger)
{
if (!issuerUrl.ToString().EndsWith("/"))
{
issuerUrl = new Uri(issuerUrl.ToString() + "/");
}
this.IssuerUrl = issuerUrl.ThrowIfNull(nameof(issuerUrl));
this.Logger = logger;
this.IssuerUrl = issuerUrl;
}
/// <summary>
/// Perform a "pre-flight" check to see if we can connect to AD.
/// </summary>
public async Task VerifyActiveDirectoryConnectivity()
{
await Task.Run(() =>
{
//
// Check if computer is domain joined at all
// (irrespective of the user we're running as).
//
Domain domain;
string domainContainer;
try
{
domain = Domain.GetComputerDomain();
domainContainer = domain
.GetDirectoryEntry()
.Properties["distinguishedName"]
.Value as string;
this.Logger.Info(
"Computer joined to domain '{0}', ({1})",
domain.Name,
domainContainer);
}
catch (Exception e)
{
throw new TokenAcquisitionException(
"The current computer is not domain-joined and can't use " +
"integrated windows authentication", e);
}
//
// Check if we can lookup the server's SPN. If that fails, we
// know that obtaining a Kerberos ticket will fail (causing a
// fallback to NTLM).
//
var spn = this.ServicePrincipalName;
try
{
using (var root = new DirectoryEntry())
using (var searcher = new DirectorySearcher(root)
{
Filter = $"(servicePrincipalName={spn})"
})
{
var result = searcher.FindOne();
if (result == null)
{
throw new ActiveDirectoryObjectNotFoundException(
$"SPN '{spn}' not found in directory");
}
this.Logger.Info("SPN '{0}' resolved to '{1}'", spn, result.Path);
}
}
catch (Exception e)
{
throw new TokenAcquisitionException(
$"The Kerberos SPN '{spn}' does not exist in Active Directory. " +
$"Add the SPN to the service account used by AD FS to enable " +
$"Kerberos authentication.", e);
}
});
}
public async Task<ISubjectToken> AcquireTokenAsync(
TokenAcquisitionOptions options,
CancellationToken cancellationToken)
{
if (options.HasFlag(TokenAcquisitionOptions.ExtensiveValidation))
{
await VerifyActiveDirectoryConnectivity()
.ConfigureAwait(false);
}
return await AcquireTokenCoreAsync(cancellationToken)
.ConfigureAwait(false);
}
protected abstract Task<ISubjectToken> AcquireTokenCoreAsync(
CancellationToken cancellationToken);
/// <summary>
/// Helper class for parsing AD FS responses.
///
/// AD FS return errors in HTML format. These HTML pages are
/// sometimes not well-formed XML, so it's not possible
/// to parse them with an XML parser. MSHTML can't be used
/// either because it'll conflict with IE ESC which is enabled
/// on most servers. Thus, use RegEx.
/// </summary>
internal class HtmlResponse
{
public HtmlResponse(string document)
{
document = document
.Replace("\n", string.Empty)
.Replace("\r", string.Empty);
this.IsSamlLoginForm = new Regex(@"action=.*SAMLRequest=.*")
.IsMatch(document);
var errorMatch = new Regex(@"(MSIS(\d+):[^<]*)").Match(document);
if (errorMatch.Success)
{
this.Error = WebUtility.HtmlDecode(errorMatch.Groups[1].Value);
}
//
// Extract input element.
//
var input = new Regex("<input.*name\\s*=\\s*\"SAMLResponse\"[^>]*")
.Match(document);
this.IsSamlPostbackForm = input.Success;
if (this.IsSamlPostbackForm)
{
//
// Extract value.
//
var value = new Regex("value\\s*=\\s*\"([^\"]*)\"").Match(input.Value);
if (value.Success)
{
this.SamlResponse = value.Groups[1].Value;
}
}
}
/// <summary>
/// Check if the page contains a login form for SAML/POST
/// login.
/// </summary>
public bool IsSamlLoginForm { get; }
/// <summary>
/// Check if the page contains a postback-form for SAML/POST
/// login.
/// </summary>
public bool IsSamlPostbackForm { get; }
/// <summary>
/// Exract SAML response, if any.
/// </summary>
public string SamlResponse { get; }
/// <summary>
/// Error message, if any.
/// </summary>
public string Error { get; }
}
}
}